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
@@ -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")
}
}