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
+17 -1
View File
@@ -158,10 +158,12 @@ val integrationTestClasses =
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
tasks.withType<Test>().configureEach {
if (name.endsWith("UnitTest")) {
filter {
(integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) }
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
}
}
testLogging {
@@ -197,6 +199,20 @@ tasks.register<Test>("demoScreenshots") {
outputs.upToDateWhen { false }
}
tasks.register<Test>("queryTiming") {
description = "Measures SQL query timings for the logging threshold (not a CI gate)"
group = "verification"
val unitTest = tasks.named<Test>("testDebugUnitTest")
dependsOn("compileDebugUnitTestKotlin")
testClassesDirs = files(provider { unitTest.get().testClassesDirs })
classpath = files(provider { unitTest.get().classpath })
filter {
includeTestsMatching(queryTimingClass)
}
providers.gradleProperty("measureSongs").orNull?.let { systemProperty("measure.songs", it) }
outputs.upToDateWhen { false }
}
dependencies {
implementation(platform(libs.androidx.compose.bom))
implementation(libs.androidx.core.ktx)
+10
View File
@@ -47,6 +47,16 @@
<receiver
android:name=".data.download.DownloadCancelReceiver"
android:exported="false" />
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>
@@ -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)) }
}
}
+4
View File
@@ -57,6 +57,10 @@
<string name="settings_about_actions_licenses">Licenses</string>
<string name="settings_about_actions_project_homepage">Project homepage</string>
<string name="settings_about_actions_share_logs">Send logs</string>
<string name="settings_about_actions_share_logs_description">Share the recent app logs with the developer</string>
<string name="settings_about_share_logs_empty">No logs to send yet</string>
<string name="settings_about_share_logs_subject">Subtracks logs</string>
<string name="settings_about_actions_support">Support the developer</string>
<string name="settings_about_name">About</string>
<string name="settings_about_version">Version %1$s</string>
+5
View File
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<files-path name="logs" path="logs/" />
<cache-path name="log_dumps" path="log_dumps/" />
</paths>
@@ -0,0 +1,107 @@
package com.subtracks.data.db
import androidx.room3.migration.Migration
import androidx.sqlite.SQLiteConnection
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.sqlite.execSQL
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.test.runTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class DbLoggingTest {
private val executions = mutableListOf<SqlExecution>()
private fun driver() = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
@Test
fun forwardsConnectionPoolFlag() {
val delegate = BundledSQLiteDriver()
assertEquals(delegate.hasConnectionPool, LoggingSQLiteDriver(delegate) {}.hasConnectionPool)
}
@Test
fun recordsSuccessfulSelect() {
driver().open(":memory:").use { connection ->
connection.prepare("CREATE TABLE t (id INTEGER PRIMARY KEY, name TEXT)").use { it.step() }
connection.prepare("INSERT INTO t (name) VALUES (?)").use {
it.bindText(1, "a")
it.step()
}
connection.prepare("SELECT * FROM t").use { statement ->
while (statement.step()) {
}
}
}
val select = executions.last { it.sql.startsWith("SELECT") }
assertNull(select.error)
assertEquals(1L, select.rows)
assertTrue(select.durationNanos > 0)
}
@Test
fun recordsPrepareError() {
driver().open(":memory:").use { connection ->
runCatching { connection.prepare("THIS IS NOT SQL") }
}
val failed = executions.single()
assertNotNull(failed.error)
assertEquals("THIS IS NOT SQL", failed.sql)
}
@Test
fun recordsStepError() {
driver().open(":memory:").use { connection ->
connection.prepare("CREATE TABLE t (id INTEGER PRIMARY KEY)").use { it.step() }
connection.prepare("INSERT INTO t (id) VALUES (1)").use { it.step() }
runCatching {
connection.prepare("INSERT INTO t (id) VALUES (1)").use { it.step() }
}
}
val failed = executions.last { it.error != null }
assertTrue(failed.sql.startsWith("INSERT"))
}
@Test
fun loggedMigrationRunsDelegate() =
runTest {
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) {}
val migration =
LoggedMigration(
object : Migration(1, 2) {
override suspend fun migrate(connection: SQLiteConnection) {
connection.execSQL("CREATE TABLE m (id INTEGER)")
}
},
)
driver.open(":memory:").use { connection ->
migration.migrate(connection)
connection.prepare("SELECT count(*) FROM m").use { statement ->
statement.step()
assertEquals(0L, statement.getLong(0))
}
}
}
@Test
fun loggedMigrationRethrows() =
runTest {
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) {}
val migration =
LoggedMigration(
object : Migration(1, 2) {
override suspend fun migrate(connection: SQLiteConnection) {
error("nope")
}
},
)
driver.open(":memory:").use { connection ->
assertTrue(runCatching { migration.migrate(connection) }.isFailure)
}
}
}
@@ -0,0 +1,312 @@
package com.subtracks.data.db
import android.content.Context
import androidx.paging.PagingSource
import androidx.room3.Room
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.subtracks.data.model.Album
import com.subtracks.data.model.Artist
import com.subtracks.data.model.Playlist
import com.subtracks.data.model.PlaylistSong
import com.subtracks.data.model.Song
import com.subtracks.data.model.Source
import com.subtracks.data.source.MusicSource
import com.subtracks.data.source.StarType
import com.subtracks.data.sync.SyncService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.test.runTest
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.io.File
import java.util.Collections
import kotlin.math.max
@RunWith(AndroidJUnit4::class)
class QueryTimingMeasurementTest {
private val executions: MutableList<SqlExecution> = Collections.synchronizedList(mutableListOf())
private val timings = linkedMapOf<String, MutableList<Double>>()
private lateinit var db: SubtracksDatabase
@Before
fun setUp() {
val context = ApplicationProvider.getApplicationContext<Context>()
val driver = LoggingSQLiteDriver(BundledSQLiteDriver()) { executions += it }
db = Room.inMemoryDatabaseBuilder(context, SubtracksDatabase::class.java).setDriver(driver).build()
}
@After
fun tearDown() = db.close()
@Test
fun measure() =
runTest {
val songCount = System.getProperty("measure.songs")?.toIntOrNull() ?: 20_000
val items = items(songCount)
db.sourcesDao().upsertSource(
Source(id = 1, name = "bench", address = "http://localhost", isActive = true, createdAt = 0),
)
val source = FakeSource(items)
executions.clear()
val firstSyncMs = wall { SyncService(db, source).sync() }
val firstSyncExecutions = executions.toList()
executions.clear()
val secondSyncMs = wall { SyncService(db, source).sync() }
val secondSyncExecutions = executions.toList()
executions.clear()
measureReads()
val readExecutions = executions.toList()
val report =
buildReport(
songCount = songCount,
firstSyncMs = firstSyncMs,
secondSyncMs = secondSyncMs,
firstSyncExecutions = firstSyncExecutions,
secondSyncExecutions = secondSyncExecutions,
readExecutions = readExecutions,
)
val out = File("build/query-timing.txt")
out.parentFile?.mkdirs()
out.writeText(report)
print(report)
}
private suspend fun measureReads() {
val dao = db.libraryDao()
timed("albums first page (name)") { dao.albumsByName(1, 0, "").firstPage() }
timed("albums first page (artist)") { dao.albumsByArtist(1, 0, "").firstPage() }
timed("albums first page (year)") { dao.albumsByYear(1, 0, "").firstPage() }
timed("albums first page (added)") { dao.albumsByRecentlyAdded(1, 0, "").firstPage() }
timed("albums first page (starred)") { dao.albumsByStarred(1, 0, "").firstPage() }
timed("albums first page (frequent)") { dao.albumsByFrequent(1, 0, "").firstPage() }
timed("albums first page (recent)") { dao.albumsByRecent(1, 0, "").firstPage() }
timed("artists first page (name)") { dao.artistsByName(1, 0, "").firstPage() }
timed("artists first page (frequent)") { dao.artistsByFrequent(1, 0, "").firstPage() }
timed("playlists first page (name)") { dao.playlistsByName(1, "").firstPage() }
timed("albums deep page") { dao.albumsByName(1, 0, "").deepPage(1000) }
timed("albums search FTS") { dao.albumsByName(1, 0, "Album 1").firstPage() }
timed("albums search short") { dao.albumsByName(1, 0, "al").firstPage() }
timed("songs search FTS") { dao.searchSongs(1, "Song", 50).first() }
timed("home recently played") { dao.recentlyPlayedAlbums(1, 20).first() }
timed("home most played") { dao.mostPlayedAlbums(1, 20).first() }
timed("home recently added") { dao.recentlyAddedAlbums(1, 20).first() }
timed("home recently starred") { dao.recentlyStarredSongs(1, 20).first() }
timed("home genres") { dao.genresByMostPlayed(1).first() }
timed("home decades") { dao.decades(1).first() }
timed("recompute play data") { dao.recomputePlayData(1) }
}
private suspend fun <T : Any> PagingSource<Int, T>.firstPage(): List<T> =
(
load(
PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false),
) as PagingSource.LoadResult.Page
).data
private suspend fun <T : Any> PagingSource<Int, T>.deepPage(offset: Int): List<T> =
(
load(
PagingSource.LoadParams.Append(key = offset, loadSize = 100, placeholdersEnabled = false),
) as PagingSource.LoadResult.Page
).data
private suspend fun <T> timed(
name: String,
iterations: Int = 10,
block: suspend () -> T,
) {
runCatching { block() }
val samples = timings.getOrPut(name) { mutableListOf() }
repeat(iterations) {
val start = System.nanoTime()
val result = runCatching { block() }
if (result.isSuccess) samples += (System.nanoTime() - start) / 1e6
}
}
private suspend fun wall(block: suspend () -> Unit): Double {
val start = System.nanoTime()
block()
return (System.nanoTime() - start) / 1e6
}
private fun buildReport(
songCount: Int,
firstSyncMs: Double,
secondSyncMs: Double,
firstSyncExecutions: List<SqlExecution>,
secondSyncExecutions: List<SqlExecution>,
readExecutions: List<SqlExecution>,
): String =
buildString {
appendLine("SQL query timing measurement")
appendLine("songs=$songCount albums=${songCount / 10} artists=${max(1, songCount / 200)}")
appendLine()
appendLine("sync: first=%.1fms second(no-op)=%.1fms".format(firstSyncMs, secondSyncMs))
appendLine()
appendLine("=== named measurements (ms) ===")
appendLine("%-32s %6s %8s %8s %8s %8s".format("measurement", "n", "p50", "p95", "p99", "max"))
timings.forEach { (name, samples) ->
if (samples.isEmpty()) return@forEach
val sorted = samples.sorted()
appendLine(
"%-32s %6d %8.2f %8.2f %8.2f %8.2f".format(
name,
sorted.size,
pct(sorted, 50),
pct(sorted, 95),
pct(sorted, 99),
sorted.last(),
),
)
}
appendLine()
appendLine("=== driver executions ===")
appendLine(overThresholds("first sync", firstSyncExecutions))
appendLine(overThresholds("second sync", secondSyncExecutions))
appendLine(overThresholds("reads", readExecutions))
appendLine()
appendLine("=== slowest first-sync executions ===")
firstSyncExecutions.sortedByDescending { it.durationMillis }.take(15).forEach {
appendLine(" %8.2fms rows=%-6d %s".format(it.durationMillis, it.rows, it.sql.replace(Regex("\\s+"), " ").take(140)))
}
appendLine("=== slowest read executions ===")
readExecutions.sortedByDescending { it.durationMillis }.take(15).forEach {
appendLine(" %8.2fms rows=%-6d %s".format(it.durationMillis, it.rows, it.sql.replace(Regex("\\s+"), " ").take(140)))
}
}
private fun overThresholds(
label: String,
records: List<SqlExecution>,
): String {
val thresholds = listOf(10, 25, 50, 100, 250, 500)
val counts = thresholds.joinToString { t -> ">${t}ms=${records.count { it.durationMillis > t }}" }
return "$label: n=${records.size} $counts"
}
private fun pct(
sorted: List<Double>,
percentile: Int,
): Double = sorted[((percentile / 100.0) * (sorted.size - 1)).toInt()]
internal data class Items(
val artists: List<Artist>,
val albums: List<Album>,
val songs: List<Song>,
val playlists: List<Playlist>,
val playlistSongs: List<PlaylistSong>,
)
private fun items(songCount: Int): Items {
val words = listOf("Midnight", "Abbey", "Blue", "Golden", "Silent", "Rolling", "Purple", "Electric")
val artistCount = max(1, songCount / 200)
val albumCount = max(1, songCount / 10)
val artists =
(0 until artistCount).map { i ->
Artist(sourceId = 1, id = "ar$i", name = "Artist ${words[i % words.size]} $i", albumCount = 10, starred = null)
}
val albums =
(0 until albumCount).map { i ->
Album(
sourceId = 1,
id = "al$i",
artistId = "ar${i / 20}",
name = "${words[i % words.size]} Album $i",
albumArtist = "Artist ${i / 20}",
created = 1_000_000L + i,
coverArt = null,
genre = null,
year = (1950 + i % 60).toLong(),
starred = if (i % 11 == 0) 100L + i else null,
songCount = 10,
)
}
val songs =
(0 until songCount).map { i ->
Song(
sourceId = 1,
id = "s$i",
albumId = "al${i / 10}",
artistId = "ar${i / 200}",
title = "${words[i % words.size]} Song $i",
album = "Album ${i / 10}",
artist = "Artist ${i / 200}",
duration = 200,
track = (i % 10 + 1).toLong(),
disc = 1,
starred = if (i % 7 == 0) 1000L + i else null,
genre = null,
created = 1_000_000L + i,
playCount = (i % 13).toLong(),
played = if (i % 5 == 0) 1_700_000_000_000L + i else null,
genres = listOf("genre${i % 50}"),
)
}
val playlistCount = max(1, songCount / 100)
val playlists =
(0 until playlistCount).map { i ->
Playlist(
sourceId = 1,
id = "pl$i",
name = "Playlist $i",
comment = null,
coverArt = null,
songCount = 20,
created = 1_000_000L + i,
changed = 2_000_000L + i,
duration = 3600,
)
}
val playlistSongs =
(0 until playlistCount).flatMap { p ->
(0 until 20).map { s -> PlaylistSong(1, "pl$p", "s${(p * 20 + s) % songCount}", s.toLong()) }
}
return Items(artists, albums, songs, playlists, playlistSongs)
}
}
private class FakeSource(
private val items: QueryTimingMeasurementTest.Items,
private val batchSize: Int = 500,
) : MusicSource {
override val id: Long = 1
override suspend fun ping() = Unit
override suspend fun setStar(
type: StarType,
id: String,
starred: Boolean,
) = Unit
override suspend fun scrobble(
songId: String,
submission: Boolean,
time: Long?,
) = Unit
override fun artists(): Flow<List<Artist>> = batches(items.artists)
override fun albums(): Flow<List<Album>> = batches(items.albums)
override fun songs(): Flow<List<Song>> = batches(items.songs)
override fun playlists(): Flow<List<Playlist>> = batches(items.playlists)
override fun playlistSongs(playlistIds: List<String>): Flow<List<PlaylistSong>> = batches(items.playlistSongs)
private fun <T> batches(list: List<T>): Flow<List<T>> =
flow {
list.chunked(batchSize).forEach { emit(it) }
}
}
@@ -0,0 +1,134 @@
package com.subtracks.log
import com.subtracks.data.db.SqlExecution
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
import java.util.zip.ZipFile
class LogFileStoreTest {
@get:Rule
val folder = TemporaryFolder()
private val root: File by lazy { folder.newFolder("logs") }
@Test
fun writesFormattedLine() {
val store = LogFileStore(root, LogLevel.VERBOSE)
store.write(LogLevel.INFO, "sync", "hello world", null)
store.flush()
val text = store.files().single().readText()
assertTrue(text, Regex("""\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d{3} I sync hello world""").containsMatchIn(text))
}
@Test
fun redactsSubsonicParams() {
val store = LogFileStore(root, LogLevel.VERBOSE)
store.write(LogLevel.INFO, "http", "http://host/rest/stream?u=alice&p=hunter2&s=salt&t=tok&id=7", null)
store.flush()
val text = store.files().single().readText()
assertTrue(text, text.contains("u=REDACTED"))
assertTrue(text, text.contains("p=REDACTED"))
assertTrue(text, text.contains("&id=7"))
assertFalse(text, text.contains("hunter2"))
assertFalse(text, text.contains("salt"))
assertFalse(text, text.contains("tok"))
}
@Test
fun redactsBasicAuth() {
val store = LogFileStore(root, LogLevel.VERBOSE)
store.write(LogLevel.INFO, "http", "http://alice:hunter2@music.example.org/rest/ping", null)
store.flush()
val text = store.files().single().readText()
assertTrue(text, text.contains("http://REDACTED@music.example.org/rest/ping"))
assertFalse(text, text.contains("hunter2"))
}
@Test
fun includesStackTrace() {
val store = LogFileStore(root, LogLevel.VERBOSE)
store.write(LogLevel.ERROR, "sql", "boom", IllegalStateException("kaboom"))
store.flush()
val text = store.files().single().readText()
assertTrue(text, text.contains("IllegalStateException: kaboom"))
}
@Test
fun dropsBelowMinimumLevel() {
val store = LogFileStore(root, LogLevel.WARN)
store.write(LogLevel.INFO, "x", "quiet", null)
store.write(LogLevel.WARN, "x", "loud", null)
store.flush()
val text = store.files().single().readText()
assertFalse(text, text.contains("quiet"))
assertTrue(text, text.contains("loud"))
}
@Test
fun rotatesWhenFileExceedsCap() {
val store = LogFileStore(root, LogLevel.VERBOSE, maxFileBytes = 1)
repeat(4) { store.write(LogLevel.INFO, "x", "line $it", null) }
store.flush()
assertTrue(store.files().size >= 4)
}
@Test
fun prunesToMaxFiles() {
val store = LogFileStore(root, LogLevel.VERBOSE, maxFileBytes = 1, maxFiles = 3)
repeat(10) { store.write(LogLevel.INFO, "x", "line $it", null) }
store.flush()
assertEquals(3, store.files().size)
}
@Test
fun exportZipContainsLogs() {
val store = LogFileStore(root, LogLevel.VERBOSE)
store.write(LogLevel.INFO, "x", "one", null)
store.flush()
val zip = store.exportZip(folder.newFolder("out"))
assertTrue(zip != null)
ZipFile(zip!!).use { archive ->
assertEquals(1, archive.size())
assertTrue(
archive
.entries()
.nextElement()
.name
.endsWith(".log"),
)
}
}
@Test
fun exportZipIsNullWhenEmpty() {
val store = LogFileStore(root, LogLevel.VERBOSE)
assertNull(store.exportZip(folder.newFolder("out")))
}
@Test
fun slowQueryPredicateExcludesNonQueriesAndInternalSql() {
assertTrue(isSlowQuery(execution(150.0, "SELECT * FROM songs"), 100.0))
assertFalse(isSlowQuery(execution(50.0, "SELECT * FROM songs"), 100.0))
assertFalse(isSlowQuery(execution(150.0, "BEGIN IMMEDIATE TRANSACTION"), 100.0))
assertFalse(isSlowQuery(execution(150.0, "SELECT * FROM room_table_modification_log WHERE invalidated = 1"), 100.0))
}
@Test
fun classifiesDataStatements() {
assertTrue(isDataStatement("insert into t values (1)"))
assertTrue(isDataStatement(" SELECT 1"))
assertFalse(isDataStatement("PRAGMA user_version"))
assertFalse(isDataStatement("END TRANSACTION"))
}
private fun execution(
milliseconds: Double,
sql: String,
) = SqlExecution(sql, (milliseconds * 1_000_000).toLong(), 0, null)
}
@@ -362,6 +362,7 @@ class AppScreenshotTest {
onAddServer = {},
onOpenDownloads = {},
onOpenLicenses = {},
onShareLogs = {},
onBack = {},
)
}
@@ -67,6 +67,7 @@ class LicensesScreenTest {
onAddServer = {},
onOpenDownloads = {},
onOpenLicenses = { opened = true },
onShareLogs = {},
onBack = {},
)
}