From 7a890094fc8b58cc74470ee78888eaa7b793e10c Mon Sep 17 00:00:00 2001 From: austinried <4966622+austinried@users.noreply.github.com> Date: Thu, 8 Oct 2026 19:34:46 +0000 Subject: [PATCH] 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. --- app/build.gradle.kts | 18 +- app/src/main/AndroidManifest.xml | 10 + .../main/kotlin/com/subtracks/SubtracksApp.kt | 9 +- .../kotlin/com/subtracks/data/db/Databases.kt | 12 +- .../kotlin/com/subtracks/data/db/DbLogging.kt | 125 +++++++ .../com/subtracks/data/sync/SyncManager.kt | 8 +- app/src/main/kotlin/com/subtracks/log/Log.kt | 165 +++++++++ .../kotlin/com/subtracks/log/LogFileStore.kt | 145 ++++++++ .../subtracks/playback/Media3PlayerHandle.kt | 16 +- .../com/subtracks/playback/PlaybackService.kt | 8 +- .../subtracks/ui/settings/SettingsScreen.kt | 30 ++ app/src/main/res/values/strings.xml | 4 + app/src/main/res/xml/file_paths.xml | 5 + .../com/subtracks/data/db/DbLoggingTest.kt | 107 ++++++ .../data/db/QueryTimingMeasurementTest.kt | 312 ++++++++++++++++++ .../com/subtracks/log/LogFileStoreTest.kt | 134 ++++++++ .../com/subtracks/ui/AppScreenshotTest.kt | 1 + .../ui/settings/LicensesScreenTest.kt | 1 + 18 files changed, 1084 insertions(+), 26 deletions(-) create mode 100644 app/src/main/kotlin/com/subtracks/data/db/DbLogging.kt create mode 100644 app/src/main/kotlin/com/subtracks/log/Log.kt create mode 100644 app/src/main/kotlin/com/subtracks/log/LogFileStore.kt create mode 100644 app/src/main/res/xml/file_paths.xml create mode 100644 app/src/test/kotlin/com/subtracks/data/db/DbLoggingTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/data/db/QueryTimingMeasurementTest.kt create mode 100644 app/src/test/kotlin/com/subtracks/log/LogFileStoreTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 35011857..137b8ad6 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -158,10 +158,12 @@ val integrationTestClasses = val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest" +val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest" + tasks.withType().configureEach { if (name.endsWith("UnitTest")) { filter { - (integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) } + (integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) } } } testLogging { @@ -197,6 +199,20 @@ tasks.register("demoScreenshots") { outputs.upToDateWhen { false } } +tasks.register("queryTiming") { + description = "Measures SQL query timings for the logging threshold (not a CI gate)" + group = "verification" + val unitTest = tasks.named("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) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6a1ea6c8..30fe98e1 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -47,6 +47,16 @@ + + + + diff --git a/app/src/main/kotlin/com/subtracks/SubtracksApp.kt b/app/src/main/kotlin/com/subtracks/SubtracksApp.kt index 8190e751..cd2ed7dc 100644 --- a/app/src/main/kotlin/com/subtracks/SubtracksApp.kt +++ b/app/src/main/kotlin/com/subtracks/SubtracksApp.kt @@ -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 = diff --git a/app/src/main/kotlin/com/subtracks/data/db/Databases.kt b/app/src/main/kotlin/com/subtracks/data/db/Databases.kt index a499eb49..7a3dd96a 100644 --- a/app/src/main/kotlin/com/subtracks/data/db/Databases.kt +++ b/app/src/main/kotlin/com/subtracks/data/db/Databases.kt @@ -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(context, "subtracks.db") - .setDriver(BundledSQLiteDriver()) - .addMigrations(*MIGRATIONS) + .setDriver(driver) + .addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray()) + .addCallback(SqliteLifecycleLogging) .build() +} diff --git a/app/src/main/kotlin/com/subtracks/data/db/DbLogging.kt b/app/src/main/kotlin/com/subtracks/data/db/DbLogging.kt new file mode 100644 index 00000000..b62cd807 --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/data/db/DbLogging.kt @@ -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") + } +} diff --git a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt index 9272e0fb..11ef7d79 100644 --- a/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt +++ b/app/src/main/kotlin/com/subtracks/data/sync/SyncManager.kt @@ -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() diff --git a/app/src/main/kotlin/com/subtracks/log/Log.kt b/app/src/main/kotlin/com/subtracks/log/Log.kt new file mode 100644 index 00000000..7134a58f --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/log/Log.kt @@ -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 = 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) diff --git a/app/src/main/kotlin/com/subtracks/log/LogFileStore.kt b/app/src/main/kotlin/com/subtracks/log/LogFileStore.kt new file mode 100644 index 00000000..cf671f9f --- /dev/null +++ b/app/src/main/kotlin/com/subtracks/log/LogFileStore.kt @@ -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 = + 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") diff --git a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt index 8f621b88..c172d2e9 100644 --- a/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt +++ b/app/src/main/kotlin/com/subtracks/playback/Media3PlayerHandle.kt @@ -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" diff --git a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt index d5bafb6f..fcdfd2c4 100644 --- a/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt +++ b/app/src/main/kotlin/com/subtracks/playback/PlaybackService.kt @@ -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) } } diff --git a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt index 657abc4a..3423256f 100644 --- a/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt +++ b/app/src/main/kotlin/com/subtracks/ui/settings/SettingsScreen.kt @@ -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 = 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)) } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 1ccbea74..3e441827 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -57,6 +57,10 @@ Licenses Project homepage + Send logs + Share the recent app logs with the developer + No logs to send yet + Subtracks logs Support the developer About Version %1$s diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 00000000..918fe209 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/test/kotlin/com/subtracks/data/db/DbLoggingTest.kt b/app/src/test/kotlin/com/subtracks/data/db/DbLoggingTest.kt new file mode 100644 index 00000000..1d494412 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/db/DbLoggingTest.kt @@ -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() + + 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) + } + } +} diff --git a/app/src/test/kotlin/com/subtracks/data/db/QueryTimingMeasurementTest.kt b/app/src/test/kotlin/com/subtracks/data/db/QueryTimingMeasurementTest.kt new file mode 100644 index 00000000..25614c7e --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/data/db/QueryTimingMeasurementTest.kt @@ -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 = Collections.synchronizedList(mutableListOf()) + private val timings = linkedMapOf>() + private lateinit var db: SubtracksDatabase + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + 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 PagingSource.firstPage(): List = + ( + load( + PagingSource.LoadParams.Refresh(key = null, loadSize = 100, placeholdersEnabled = false), + ) as PagingSource.LoadResult.Page + ).data + + private suspend fun PagingSource.deepPage(offset: Int): List = + ( + load( + PagingSource.LoadParams.Append(key = offset, loadSize = 100, placeholdersEnabled = false), + ) as PagingSource.LoadResult.Page + ).data + + private suspend fun 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, + secondSyncExecutions: List, + readExecutions: List, + ): 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, + ): 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, + percentile: Int, + ): Double = sorted[((percentile / 100.0) * (sorted.size - 1)).toInt()] + + internal data class Items( + val artists: List, + val albums: List, + val songs: List, + val playlists: List, + val playlistSongs: List, + ) + + 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> = batches(items.artists) + + override fun albums(): Flow> = batches(items.albums) + + override fun songs(): Flow> = batches(items.songs) + + override fun playlists(): Flow> = batches(items.playlists) + + override fun playlistSongs(playlistIds: List): Flow> = batches(items.playlistSongs) + + private fun batches(list: List): Flow> = + flow { + list.chunked(batchSize).forEach { emit(it) } + } +} diff --git a/app/src/test/kotlin/com/subtracks/log/LogFileStoreTest.kt b/app/src/test/kotlin/com/subtracks/log/LogFileStoreTest.kt new file mode 100644 index 00000000..af8745e5 --- /dev/null +++ b/app/src/test/kotlin/com/subtracks/log/LogFileStoreTest.kt @@ -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) +} diff --git a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt index 0ca73d3e..b1e972cb 100644 --- a/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/AppScreenshotTest.kt @@ -362,6 +362,7 @@ class AppScreenshotTest { onAddServer = {}, onOpenDownloads = {}, onOpenLicenses = {}, + onShareLogs = {}, onBack = {}, ) } diff --git a/app/src/test/kotlin/com/subtracks/ui/settings/LicensesScreenTest.kt b/app/src/test/kotlin/com/subtracks/ui/settings/LicensesScreenTest.kt index b6dd77c1..52b7ae76 100644 --- a/app/src/test/kotlin/com/subtracks/ui/settings/LicensesScreenTest.kt +++ b/app/src/test/kotlin/com/subtracks/ui/settings/LicensesScreenTest.kt @@ -67,6 +67,7 @@ class LicensesScreenTest { onAddServer = {}, onOpenDownloads = {}, onOpenLicenses = { opened = true }, + onShareLogs = {}, onBack = {}, ) }