logging: add file-based logging and a Send logs option

Capture logs to rotating app-private files with Subsonic credential and URL basic-auth redaction, mirror to Logcat, and capture uncaught crashes. Log slow SQL (>100ms), SQL errors, migrations and DB lifecycle via a decorating SQLiteDriver. Add a Send logs row in Settings that zips the logs and opens the share sheet through a FileProvider.
This commit is contained in:
austinried
2026-10-09 07:58:54 +00:00
parent 4012cc0272
commit 7a890094fc
18 changed files with 1084 additions and 26 deletions
@@ -8,16 +8,23 @@ import coil3.network.okhttp.OkHttpNetworkFetcherFactory
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.repo.ArtworkSeedStore
import com.subtracks.di.appModule
import com.subtracks.log.Log
import com.subtracks.ui.theme.ArtworkSeedCache
import org.koin.core.context.startKoin
import org.koin.core.context.stopKoin
class SubtracksApp : Application() {
private val http = UserAgent.httpClient()
private val http =
UserAgent
.httpClient()
.newBuilder()
.addInterceptor(Log.interceptor())
.build()
@OptIn(DelicateCoilApi::class)
override fun onCreate() {
super.onCreate()
Log.init(this)
stopKoin()
SingletonImageLoader.reset()
val koin =
@@ -3,10 +3,14 @@ package com.subtracks.data.db
import android.content.Context
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import com.subtracks.log.Log
fun createAndroidDatabase(context: Context): SubtracksDatabase =
Room
fun createAndroidDatabase(context: Context): SubtracksDatabase {
val driver = LoggingSQLiteDriver(BundledSQLiteDriver(), Log::sql)
return Room
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
.setDriver(BundledSQLiteDriver())
.addMigrations(*MIGRATIONS)
.setDriver(driver)
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
.addCallback(SqliteLifecycleLogging)
.build()
}
@@ -0,0 +1,125 @@
package com.subtracks.data.db
import androidx.room3.RoomDatabase
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.SQLiteDriver
import androidx.sqlite.SQLiteStatement
import com.subtracks.log.Log
data class SqlExecution(
val sql: String,
val durationNanos: Long,
val rows: Long,
val error: Throwable?,
) {
val durationMillis: Double get() = durationNanos / 1_000_000.0
}
class LoggingSQLiteDriver(
private val delegate: SQLiteDriver,
private val onExecution: (SqlExecution) -> Unit,
) : SQLiteDriver {
override val hasConnectionPool: Boolean
get() = delegate.hasConnectionPool
override fun open(fileName: String): SQLiteConnection = LoggingConnection(delegate.open(fileName), onExecution)
}
private class LoggingConnection(
private val delegate: SQLiteConnection,
private val onExecution: (SqlExecution) -> Unit,
) : SQLiteConnection by delegate {
override fun prepare(sql: String): SQLiteStatement {
val start = System.nanoTime()
return try {
LoggingStatement(delegate.prepare(sql), sql, onExecution)
} catch (failure: Throwable) {
onExecution(SqlExecution(sql, System.nanoTime() - start, 0, failure))
throw failure
}
}
}
private class LoggingStatement(
private val delegate: SQLiteStatement,
private val sql: String,
private val onExecution: (SqlExecution) -> Unit,
) : SQLiteStatement by delegate {
private var startNanos = System.nanoTime()
private var rows = 0L
private var failure: Throwable? = null
private var started = false
private var reported = false
override fun step(): Boolean {
started = true
val result =
try {
delegate.step()
} catch (error: Throwable) {
failure = error
report()
throw error
}
if (result) rows++
if (!result) report()
return result
}
override fun reset() {
report()
delegate.reset()
begin()
}
override fun close() {
report()
delegate.close()
}
private fun report() {
if (!started || reported) return
reported = true
onExecution(SqlExecution(sql, System.nanoTime() - startNanos, rows, failure))
}
private fun begin() {
startNanos = System.nanoTime()
rows = 0
failure = null
started = false
reported = false
}
}
class LoggedMigration(
private val delegate: Migration,
) : Migration(delegate.startVersion, delegate.endVersion) {
override suspend fun migrate(connection: SQLiteConnection) {
val label = "${delegate.startVersion} -> ${delegate.endVersion}"
Log.i("sql", "migrating $label")
val start = System.nanoTime()
try {
delegate.migrate(connection)
} catch (failure: Throwable) {
Log.e("sql", "migration $label failed after ${(System.nanoTime() - start) / 1_000_000}ms", failure)
throw failure
}
Log.i("sql", "migrated $label in ${(System.nanoTime() - start) / 1_000_000}ms")
}
}
internal object SqliteLifecycleLogging : RoomDatabase.Callback() {
override suspend fun onCreate(connection: SQLiteConnection) {
Log.i("sql", "created fresh database")
}
override suspend fun onOpen(connection: SQLiteConnection) {
Log.i("sql", "database opened")
}
override suspend fun onDestructiveMigration(connection: SQLiteConnection) {
Log.w("sql", "destructive migration: dropping all tables")
}
}
@@ -1,6 +1,5 @@
package com.subtracks.data.sync
import android.util.Log
import com.subtracks.R
import com.subtracks.UiMessage
import com.subtracks.data.db.SubtracksDatabase
@@ -8,6 +7,7 @@ import com.subtracks.data.repo.QueueRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.ServerActionSink
import com.subtracks.data.source.subsonic.SubsonicException
import com.subtracks.log.Log
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
@@ -68,7 +68,7 @@ class SyncManager(
} catch (cancellation: CancellationException) {
throw cancellation
} catch (failure: Exception) {
Log.w(TAG, "Sync failed", failure)
Log.w("sync", "sync failed", failure)
val message =
when (failure) {
is NoServerException -> {
@@ -86,10 +86,6 @@ class SyncManager(
queueRepository.invalidateLibraryCache()
_status.value = result
}
private companion object {
const val TAG = "SubtracksSync"
}
}
private class NoServerException : IllegalStateException()
@@ -0,0 +1,165 @@
package com.subtracks.log
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.content.FileProvider
import com.subtracks.BuildConfig
import com.subtracks.R
import com.subtracks.data.db.SqlExecution
import okhttp3.Interceptor
import java.io.File
import java.io.IOException
import kotlin.math.roundToLong
import android.util.Log as AndroidLog
object Log {
private const val SLOW_QUERY_MS = 100.0
private const val MAX_SQL_CHARS = 500
private val WHITESPACE = Regex("\\s+")
private var fileStore: LogFileStore? = null
private var appContext: Context? = null
private var consoleLevel = LogLevel.DEBUG
private var crashHandlerInstalled = false
fun init(context: Context) {
val app = context.applicationContext
appContext = app
consoleLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
val fileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
fileStore = LogFileStore(File(app.filesDir, "logs"), fileLevel)
installCrashHandler()
i("app", "started ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) on Android ${Build.VERSION.SDK_INT} ${Build.MODEL}")
}
fun v(
tag: String,
message: String,
) = log(LogLevel.VERBOSE, tag, message, null)
fun d(
tag: String,
message: String,
) = log(LogLevel.DEBUG, tag, message, null)
fun i(
tag: String,
message: String,
) = log(LogLevel.INFO, tag, message, null)
fun w(
tag: String,
message: String,
throwable: Throwable? = null,
) = log(LogLevel.WARN, tag, message, throwable)
fun e(
tag: String,
message: String,
throwable: Throwable? = null,
) = log(LogLevel.ERROR, tag, message, throwable)
fun files(): List<File> = fileStore?.files().orEmpty()
fun exportZip(): File? {
val store = fileStore ?: return null
val context = appContext ?: return null
store.flush()
return store.exportZip(File(context.cacheDir, "log_dumps"))
}
fun share(
context: Context,
file: File,
) {
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val send =
Intent(Intent.ACTION_SEND).apply {
type = "application/zip"
putExtra(Intent.EXTRA_STREAM, uri)
putExtra(Intent.EXTRA_SUBJECT, context.getString(R.string.settings_about_share_logs_subject))
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(
Intent.createChooser(send, context.getString(R.string.settings_about_actions_share_logs)),
)
}
fun interceptor(): Interceptor =
Interceptor { chain ->
val request = chain.request()
d("http", "${request.method} ${request.url}")
try {
val response = chain.proceed(request)
if (response.code >= 400) {
w("http", "${request.method} ${request.url} -> ${response.code}")
}
response
} catch (failure: IOException) {
w("http", "${request.method} ${request.url} failed", failure)
throw failure
}
}
fun sql(execution: SqlExecution) {
val failure = execution.error
if (failure != null) {
e("sql", "failed: ${shortSql(execution.sql)}", failure)
return
}
if (!isSlowQuery(execution, SLOW_QUERY_MS)) return
w("sql", "slow ${execution.durationMillis.roundToLong()}ms rows=${execution.rows}: ${shortSql(execution.sql)}")
}
private fun log(
level: LogLevel,
tag: String,
message: String,
throwable: Throwable?,
) {
val redacted = redactLogs(message)
if (level.ordinal >= consoleLevel.ordinal) {
val text = if (throwable != null) "$redacted\n${AndroidLog.getStackTraceString(throwable)}" else redacted
AndroidLog.println(priority(level), tag.take(23), text)
}
fileStore?.write(level, tag, redacted, throwable)
}
private fun priority(level: LogLevel): Int =
when (level) {
LogLevel.VERBOSE -> AndroidLog.VERBOSE
LogLevel.DEBUG -> AndroidLog.DEBUG
LogLevel.INFO -> AndroidLog.INFO
LogLevel.WARN -> AndroidLog.WARN
LogLevel.ERROR -> AndroidLog.ERROR
}
private fun installCrashHandler() {
if (crashHandlerInstalled) return
crashHandlerInstalled = true
val previous = Thread.getDefaultUncaughtExceptionHandler()
Thread.setDefaultUncaughtExceptionHandler { thread, throwable ->
runCatching {
fileStore?.writeImmediately(LogLevel.ERROR, "crash", "uncaught exception on ${thread.name}", throwable)
}
previous?.uncaughtException(thread, throwable)
}
}
private fun shortSql(sql: String): String = sql.replace(WHITESPACE, " ").take(MAX_SQL_CHARS)
}
private val DATA_KEYWORDS = setOf("SELECT", "INSERT", "UPDATE", "DELETE", "WITH", "REPLACE")
private const val SQL_INTERNAL = "room_table_modification_log"
internal fun isDataStatement(sql: String): Boolean {
val normalized = sql.trimStart()
if (normalized.contains(SQL_INTERNAL)) return false
return normalized.takeWhile { !it.isWhitespace() }.uppercase() in DATA_KEYWORDS
}
internal fun isSlowQuery(
execution: SqlExecution,
thresholdMs: Double,
): Boolean = execution.error == null && execution.durationMillis >= thresholdMs && isDataStatement(execution.sql)
@@ -0,0 +1,145 @@
package com.subtracks.log
import java.io.File
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.concurrent.Executors
import java.util.zip.ZipEntry
import java.util.zip.ZipOutputStream
enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR }
fun interface LogSink {
fun log(
level: LogLevel,
tag: String,
message: String,
throwable: Throwable?,
)
}
class LogFileStore(
private val rootDir: File,
private val minLevel: LogLevel,
private val maxFileBytes: Long = DEFAULT_MAX_FILE_BYTES,
private val maxFiles: Int = DEFAULT_MAX_FILES,
) {
private val executor =
Executors.newSingleThreadExecutor { runnable ->
Thread(runnable, "log-writer").apply { isDaemon = true }
}
private val lock = Any()
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
private val stampFormat = SimpleDateFormat("yyyy-MM-dd-HHmmss-SSS", Locale.US)
private val lineFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
private var current = currentFile()
fun write(
level: LogLevel,
tag: String,
message: String,
throwable: Throwable?,
) {
if (level.ordinal < minLevel.ordinal) return
executor.execute { synchronized(lock) { append(level, tag, message, throwable) } }
}
fun writeImmediately(
level: LogLevel,
tag: String,
message: String,
throwable: Throwable?,
) {
if (level.ordinal < minLevel.ordinal) return
synchronized(lock) { append(level, tag, message, throwable) }
}
fun flush() {
executor.submit {}.get()
}
fun files(): List<File> =
rootDir
.listFiles { file -> file.isFile && file.name.endsWith(LOG_SUFFIX) }
?.sortedByDescending { it.lastModified() }
.orEmpty()
fun exportZip(destDir: File): File? {
val logs = files()
if (logs.isEmpty()) return null
destDir.mkdirs()
val zip = File(destDir, "subtracks-logs-${stampFormat.format(Date())}.zip")
ZipOutputStream(zip.outputStream().buffered()).use { out ->
logs.forEach { log ->
out.putNextEntry(ZipEntry(log.name))
log.inputStream().use { it.copyTo(out) }
out.closeEntry()
}
}
return zip
}
private fun append(
level: LogLevel,
tag: String,
message: String,
throwable: Throwable?,
) {
val text =
buildString {
append(lineFormat.format(Date()))
append(' ')
append(LEVEL_CHARS[level.ordinal])
append(' ')
append(tag)
append(' ')
append(message)
throwable?.let {
append('\n')
append(it.stackTraceToString().trimEnd())
}
append('\n')
}
rootDir.mkdirs()
if (current.length() >= maxFileBytes) roll()
current.appendText(redactLogs(text))
prune()
}
private var rollSequence = 0
private fun roll() {
if (current.exists()) {
current.renameTo(uniqueRolledFile())
}
current = currentFile()
}
private fun uniqueRolledFile(): File {
val day = dateFormat.format(Date())
while (true) {
val candidate = File(rootDir, "$day-${rollSequence++}$LOG_SUFFIX")
if (!candidate.exists()) return candidate
}
}
private fun prune() {
val logs = files()
if (logs.size > maxFiles) logs.drop(maxFiles).forEach { it.delete() }
}
private fun currentFile(): File = File(rootDir, dateFormat.format(Date()) + LOG_SUFFIX)
companion object {
const val DEFAULT_MAX_FILE_BYTES = 1L * 1024 * 1024
const val DEFAULT_MAX_FILES = 7
private const val LOG_SUFFIX = ".log"
private val LEVEL_CHARS = charArrayOf('V', 'D', 'I', 'W', 'E')
}
}
private val URL_USERINFO = Regex("([a-zA-Z][a-zA-Z0-9+.\\-]*://)[^/@\\s]+@")
private val URL_PARAM = Regex("([?&](?:u|p|s|t)=)[^&\\s]+")
internal fun redactLogs(input: String): String = input.replace(URL_USERINFO, "$1REDACTED@").replace(URL_PARAM, "$1REDACTED")
@@ -1,7 +1,6 @@
package com.subtracks.playback
import android.os.Bundle
import android.util.Log
import androidx.media3.common.C
import androidx.media3.common.Format
import androidx.media3.common.MediaItem
@@ -16,6 +15,7 @@ import com.subtracks.UiMessage
import com.subtracks.data.model.AudioEncoding
import com.subtracks.data.repo.DownloadRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.log.Log
class Media3PlayerHandle(
private val controller: MediaController,
@@ -112,13 +112,13 @@ class Media3PlayerHandle(
) = controller.replaceMediaItem(index, toMediaItem(item))
override fun seekToIndex(index: Int) {
Log.d(TAG, "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
Log.d("playback", "seekToIndex($index) playerDuration=${controller.duration} state=${controller.playbackState}")
controller.seekTo(index, 0)
}
override fun seekTo(positionMs: Long) {
Log.d(
TAG,
"playback",
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
)
@@ -136,7 +136,7 @@ class Media3PlayerHandle(
mediaItem: MediaItem?,
reason: Int,
) {
Log.d(TAG, "item=${mediaItem?.mediaId}")
Log.d("playback", "item=${mediaItem?.mediaId}")
listener.onTransition()
}
@@ -147,7 +147,7 @@ class Media3PlayerHandle(
override fun onPlayerError(error: PlaybackException) {
Log.w(
TAG,
"playback",
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
error,
)
@@ -156,7 +156,7 @@ class Media3PlayerHandle(
override fun onPlaybackStateChanged(playbackState: Int) {
Log.d(
TAG,
"playback",
"state=$playbackState position=${controller.currentPosition} " +
"playerDuration=${controller.duration} itemDuration=${currentItem?.durationMs}",
)
@@ -190,10 +190,6 @@ class Media3PlayerHandle(
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
?.getLong(EXTRA_DURATION_MS),
)
private companion object {
const val TAG = "SubtracksPlayback"
}
}
private const val EXTRA_COVER_ART_ID = "coverArtId"
@@ -5,7 +5,6 @@ import android.content.Context
import android.content.Intent
import android.os.Bundle
import android.os.SystemClock
import android.util.Log
import androidx.annotation.OptIn
import androidx.media3.common.AudioAttributes
import androidx.media3.common.C
@@ -33,6 +32,7 @@ import com.subtracks.UserAgent
import com.subtracks.data.repo.LibraryRepository
import com.subtracks.data.repo.SourceRepository
import com.subtracks.data.source.StarType
import com.subtracks.log.Log
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
@@ -186,12 +186,13 @@ class PlaybackService : MediaSessionService() {
private fun streamingClient(): OkHttpClient =
OkHttpClient
.Builder()
.addInterceptor(Log.interceptor())
.addInterceptor { chain ->
val started = SystemClock.elapsedRealtime()
val response = chain.proceed(chain.request())
Log.d(
STREAM_TAG,
"stream range=${chain.request().header("Range")} code=${response.code} " +
"stream",
"range=${chain.request().header("Range")} code=${response.code} " +
"contentRange=${response.header("Content-Range")} " +
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
)
@@ -211,7 +212,6 @@ class PlaybackService : MediaSessionService() {
}
private companion object {
const val STREAM_TAG = "SubtracksPlayback"
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
}
}
@@ -1,6 +1,7 @@
package com.subtracks.ui.settings
import android.content.res.Resources
import android.widget.Toast
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@@ -27,6 +28,7 @@ import androidx.compose.material.icons.rounded.Edit
import androidx.compose.material.icons.rounded.Favorite
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Language
import androidx.compose.material.icons.rounded.Share
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -47,11 +49,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalResources
import androidx.compose.ui.platform.LocalUriHandler
import androidx.compose.ui.res.stringResource
@@ -62,10 +66,14 @@ import com.subtracks.BuildConfig
import com.subtracks.R
import com.subtracks.data.model.Source
import com.subtracks.data.prefs.StreamQuality
import com.subtracks.log.Log
import com.subtracks.ui.components.DismissOnRequest
import com.subtracks.ui.components.rememberViewportFill
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koin.compose.viewmodel.koinViewModel
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
@@ -95,6 +103,8 @@ fun SettingsRoute(
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
val offline by viewModel.offline.collectAsStateWithLifecycle()
val context = LocalContext.current
val scope = rememberCoroutineScope()
SettingsScreen(
sources = sources,
activeSourceId = activeSourceId,
@@ -117,6 +127,16 @@ fun SettingsRoute(
onAddServer = onAddServer,
onOpenDownloads = onOpenDownloads,
onOpenLicenses = onOpenLicenses,
onShareLogs = {
scope.launch {
val zip = withContext(Dispatchers.IO) { Log.exportZip() }
if (zip != null) {
Log.share(context, zip)
} else {
Toast.makeText(context, R.string.settings_about_share_logs_empty, Toast.LENGTH_SHORT).show()
}
}
},
onBack = onBack,
dismissals = dismissals,
)
@@ -147,6 +167,7 @@ fun SettingsScreen(
onAddServer: () -> Unit,
onOpenDownloads: () -> Unit,
onOpenLicenses: () -> Unit,
onShareLogs: () -> Unit,
onBack: () -> Unit,
dismissals: Flow<Unit> = emptyFlow(),
) {
@@ -364,6 +385,15 @@ fun SettingsScreen(
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item {
ListItem(
headlineContent = { Text(stringResource(R.string.settings_about_actions_share_logs)) },
supportingContent = { Text(stringResource(R.string.settings_about_actions_share_logs_description)) },
leadingContent = { Icon(Icons.Rounded.Share, contentDescription = null) },
modifier = Modifier.clickable(onClick = onShareLogs),
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
)
}
item { Spacer(Modifier.height(fill)) }
}
}