Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
15d77f998f | ||
|
|
779131c766 | ||
|
|
9695d83b1a | ||
|
|
a8d6111aaa | ||
|
|
d4152ba3a1 | ||
|
|
79fe15da91 | ||
|
|
714440fb25 | ||
|
|
a2d89f28a6 | ||
|
|
b2b02e7c05 | ||
|
|
9d783b2301 | ||
|
|
713d9a976a | ||
|
|
c262e58a96 | ||
|
|
eb5019ea5e | ||
|
|
8fe26a36b1 | ||
|
|
7a890094fc | ||
|
|
4012cc0272 | ||
|
|
336fd9c49f | ||
|
|
5f138bde6a | ||
|
|
32360fac08 | ||
|
|
206ac8988f | ||
|
|
4fad6a78ec | ||
|
|
2439368e4e | ||
|
|
92c6f6945f | ||
|
|
bcd7bbf23a | ||
|
|
ce9e3e89a7 | ||
|
|
66ee837cbb |
@@ -30,7 +30,6 @@ android {
|
||||
buildFeatures {
|
||||
compose = true
|
||||
buildConfig = true
|
||||
resValues = true
|
||||
}
|
||||
|
||||
androidResources {
|
||||
@@ -99,10 +98,13 @@ android {
|
||||
}
|
||||
}
|
||||
|
||||
sourceSets {
|
||||
getByName("debug").res.srcDir("src/nightly/res")
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
debug {
|
||||
applicationIdSuffix = ".nightly"
|
||||
resValue("string", "app_name", "subtracks nightly")
|
||||
}
|
||||
release {
|
||||
isMinifyEnabled = true
|
||||
@@ -115,7 +117,6 @@ android {
|
||||
initWith(getByName("release"))
|
||||
applicationIdSuffix = ".nightly"
|
||||
signingConfig = signingConfigs.getByName("debug")
|
||||
resValue("string", "app_name", "subtracks nightly")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -157,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 {
|
||||
@@ -196,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)
|
||||
|
||||
@@ -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,17 +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 okhttp3.OkHttpClient
|
||||
import org.koin.core.context.startKoin
|
||||
import org.koin.core.context.stopKoin
|
||||
|
||||
class SubtracksApp : Application() {
|
||||
private val http = OkHttpClient()
|
||||
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 =
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
package com.subtracks
|
||||
|
||||
import android.os.Build
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
object UserAgent {
|
||||
val value: String = "subtracks (Android ${Build.VERSION.RELEASE}; ${BuildConfig.VERSION_NAME})"
|
||||
|
||||
fun interceptor(): Interceptor =
|
||||
Interceptor { chain ->
|
||||
chain.proceed(
|
||||
chain
|
||||
.request()
|
||||
.newBuilder()
|
||||
.header("User-Agent", value)
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
fun httpClient(): OkHttpClient = OkHttpClient.Builder().addInterceptor(interceptor()).build()
|
||||
}
|
||||
@@ -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,129 @@
|
||||
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
|
||||
import kotlinx.coroutines.CancellationException
|
||||
|
||||
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 = 0L
|
||||
private var rows = 0L
|
||||
private var failure: Throwable? = null
|
||||
private var active = false
|
||||
|
||||
override fun step(): Boolean {
|
||||
begin()
|
||||
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()
|
||||
}
|
||||
|
||||
override fun close() {
|
||||
report()
|
||||
delegate.close()
|
||||
}
|
||||
|
||||
private fun report() {
|
||||
if (!active) return
|
||||
active = false
|
||||
onExecution(SqlExecution(sql, System.nanoTime() - startNanos, rows, failure))
|
||||
}
|
||||
|
||||
// Started on the first step, not on reset: Room's statement cache resets a statement when it is
|
||||
// returned to the cache (BasePreparedStatementCache.CachedStatement.close), so timing from reset
|
||||
// would count however long the statement idled there before its next use.
|
||||
private fun begin() {
|
||||
if (active) return
|
||||
startNanos = System.nanoTime()
|
||||
rows = 0
|
||||
failure = null
|
||||
active = true
|
||||
}
|
||||
}
|
||||
|
||||
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 (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} 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")
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.Dao
|
||||
import androidx.room3.Insert
|
||||
import androidx.room3.OnConflictStrategy
|
||||
import androidx.room3.Query
|
||||
import androidx.room3.Upsert
|
||||
import com.subtracks.data.model.AlbumSongItem
|
||||
import com.subtracks.data.model.PlaybackCursor
|
||||
import com.subtracks.data.model.PlaylistSongItem
|
||||
@@ -55,7 +55,7 @@ interface QueueDao {
|
||||
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||
suspend fun cursor(): PlaybackCursor?
|
||||
|
||||
@Upsert
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun setCursor(cursor: PlaybackCursor)
|
||||
|
||||
@Query(
|
||||
|
||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
||||
import android.content.Context
|
||||
import android.os.Environment
|
||||
import androidx.core.net.toUri
|
||||
import com.subtracks.UserAgent
|
||||
import com.subtracks.data.model.DownloadError
|
||||
|
||||
class SystemDownloadEngine(
|
||||
@@ -16,6 +17,7 @@ class SystemDownloadEngine(
|
||||
.setTitle(request.title)
|
||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||
.addRequestHeader("User-Agent", UserAgent.value)
|
||||
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
||||
// one of those; the platform otherwise permits it by default.
|
||||
.setAllowedOverMetered(request.allowMetered)
|
||||
|
||||
@@ -121,6 +121,14 @@ class UserPreferences(
|
||||
|
||||
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
||||
|
||||
fun verboseLogging(): Flow<Boolean> = store.data.map { prefs -> prefs[verboseLoggingKey] ?: false }
|
||||
|
||||
suspend fun setVerboseLogging(enabled: Boolean) {
|
||||
store.edit { prefs -> prefs[verboseLoggingKey] = enabled }
|
||||
}
|
||||
|
||||
private val verboseLoggingKey = booleanPreferencesKey("verbose_logging")
|
||||
|
||||
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||
val prefs = store.data.first()
|
||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
||||
|
||||
@@ -19,6 +19,7 @@ import com.subtracks.data.model.DownloadedSong
|
||||
import com.subtracks.data.model.ListDownloadStatus
|
||||
import com.subtracks.data.model.SongDownload
|
||||
import com.subtracks.data.model.coverArtKey
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CompletableDeferred
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
@@ -79,7 +80,10 @@ class DownloadRepository(
|
||||
}
|
||||
scope.launch {
|
||||
sourceRepository.offline.collect { offline ->
|
||||
if (offline) cancelActive()
|
||||
if (offline) {
|
||||
Log.i("download", "offline: cancelling active downloads")
|
||||
cancelActive()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -140,6 +144,7 @@ class DownloadRepository(
|
||||
dir(sourceId).mkdirs()
|
||||
file(sourceId, songId).delete()
|
||||
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
||||
Log.i("download", "queued song=$songId (source $sourceId)")
|
||||
ensurePolling()
|
||||
return true
|
||||
}
|
||||
@@ -336,6 +341,7 @@ class DownloadRepository(
|
||||
|
||||
private suspend fun removeRows(rows: List<SongDownload>) {
|
||||
if (rows.isEmpty()) return
|
||||
Log.i("download", "removed ${rows.size} download(s)")
|
||||
val engineIds = rows.mapNotNull { it.engineId }
|
||||
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||
@@ -581,6 +587,14 @@ class DownloadRepository(
|
||||
},
|
||||
)
|
||||
if (updated != row) db.downloadDao().upsert(updated)
|
||||
if (updated.status != row.status) {
|
||||
when (updated.status) {
|
||||
DownloadStatus.Completed -> Log.i("download", "completed ${row.songId} (${updated.total} bytes)")
|
||||
DownloadStatus.Failed -> Log.w("download", "failed ${row.songId}: ${updated.error}")
|
||||
DownloadStatus.Running -> Log.i("download", "started ${row.songId}")
|
||||
DownloadStatus.Queued -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun markFailed(
|
||||
@@ -590,6 +604,7 @@ class DownloadRepository(
|
||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||
file(row.sourceId, row.songId).delete()
|
||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||
Log.w("download", "failed ${row.songId}: $error")
|
||||
}
|
||||
|
||||
private fun ensurePolling() {
|
||||
|
||||
@@ -24,6 +24,7 @@ import com.subtracks.data.prefs.PlaylistSort
|
||||
import com.subtracks.data.prefs.StarredFilter
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -464,14 +465,17 @@ class LibraryRepository(
|
||||
}
|
||||
val previous = starredValue(sourceId, type, id)
|
||||
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
||||
val action = if (starred) "star" else "unstar"
|
||||
return try {
|
||||
serverActions.setStar(type, id, starred)
|
||||
Log.i("star", "$action ${type.name.lowercase()} $id")
|
||||
Result.success(Unit)
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
||||
showMessage(UiMessage(R.string.star_update_failed))
|
||||
Log.w("star", "failed to $action ${type.name.lowercase()} $id", failure)
|
||||
Result.failure(failure)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.subtracks.data.model.PendingActionKind
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.source.StarType
|
||||
import com.subtracks.data.source.subsonic.SubsonicException
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
@@ -29,8 +30,13 @@ class PendingServerActionSink(
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
var lastReady: Boolean? = null
|
||||
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
||||
.collect { ready ->
|
||||
if (ready && lastReady == false) {
|
||||
Log.i("net", "connection available, replaying pending server actions")
|
||||
}
|
||||
lastReady = ready
|
||||
// The persisted flag and connectivity are authoritative, unlike the process
|
||||
// flag's default at cold start, so wait for that flag to agree before replaying.
|
||||
if (ready) {
|
||||
@@ -96,12 +102,14 @@ class PendingServerActionSink(
|
||||
for (action in dao.pending(sourceId)) {
|
||||
try {
|
||||
deliver(action)
|
||||
Log.i("net", "replayed ${action.kind.name.lowercase()} ${action.targetId}")
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
// Still unreachable: keep this and the remaining actions for the next attempt.
|
||||
if (isRetryable(failure)) return
|
||||
// The server answered and refused; drop the action rather than block the queue.
|
||||
Log.w("net", "dropped ${action.kind.name.lowercase()} ${action.targetId}", failure)
|
||||
}
|
||||
dao.delete(action.id)
|
||||
}
|
||||
@@ -139,6 +147,7 @@ class PendingServerActionSink(
|
||||
db.pendingActionDao().replace(
|
||||
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
||||
)
|
||||
Log.i("net", "queued ${kind.name.lowercase()} $targetId")
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ import com.subtracks.data.source.MusicSource
|
||||
import com.subtracks.data.source.declaredStreamLength
|
||||
import com.subtracks.data.source.streamLengthSuffix
|
||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
@@ -46,6 +47,13 @@ class SourceRepository(
|
||||
val source: SubsonicMusicSource,
|
||||
)
|
||||
|
||||
private data class ResolvedSource(
|
||||
val config: SubsonicConfig?,
|
||||
val quality: StreamQuality,
|
||||
val mode: NetworkMode,
|
||||
val concurrency: Int,
|
||||
)
|
||||
|
||||
@Volatile
|
||||
private var active: ActiveSource? = null
|
||||
|
||||
@@ -64,6 +72,7 @@ class SourceRepository(
|
||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
Log.i("net", "offline mode -> $enabled")
|
||||
scope.launch { prefs.setOfflineMode(enabled) }
|
||||
}
|
||||
|
||||
@@ -81,6 +90,7 @@ class SourceRepository(
|
||||
// Read the persisted flag before the source config can build a source or probe it, so a
|
||||
// cold start with offline on never touches the network.
|
||||
_offline.value = prefs.offlineMode().first()
|
||||
var lastMode: NetworkMode? = null
|
||||
combine(
|
||||
db.sourcesDao().activeSubsonicConfig(),
|
||||
networkMode,
|
||||
@@ -88,13 +98,23 @@ class SourceRepository(
|
||||
prefs.streamQuality(NetworkMode.Mobile),
|
||||
prefs.syncConcurrency(),
|
||||
) { config, mode, wifi, mobile, concurrency ->
|
||||
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
|
||||
}.collect { (config, quality, concurrency) ->
|
||||
ResolvedSource(config, if (mode == NetworkMode.Wifi) wifi else mobile, mode, concurrency)
|
||||
}.collect { (config, quality, mode, concurrency) ->
|
||||
if (mode != lastMode) {
|
||||
Log.i("net", "network mode -> ${mode.key}")
|
||||
lastMode = mode
|
||||
}
|
||||
val sourceChanged = config?.id != active?.id
|
||||
if (sourceChanged) {
|
||||
Log.i("net", "active source -> ${config?.name ?: "none"}")
|
||||
}
|
||||
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||
_quality.value = quality
|
||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
||||
scope.launch { runCatching { config.toClient().check("ping") } }
|
||||
scope.launch {
|
||||
runCatching { config.toClient().check("ping") }
|
||||
.onSuccess { Log.i("net", "connected (ping ok)") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.subtracks.data.source.subsonic
|
||||
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.log.Log
|
||||
import okhttp3.HttpUrl
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
@@ -20,7 +21,6 @@ class SubsonicClient(
|
||||
private val password: String,
|
||||
useTokenAuth: Boolean,
|
||||
private val http: OkHttpClient,
|
||||
private val userAgent: String = "subtracks/android",
|
||||
private val onTokenAuthUnsupported: () -> Unit = {},
|
||||
) {
|
||||
@Volatile
|
||||
@@ -61,7 +61,11 @@ class SubsonicClient(
|
||||
return try {
|
||||
execute(method, params, body)
|
||||
} catch (failure: SubsonicException) {
|
||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) throw failure
|
||||
if (failure.code !in TOKEN_AUTH_UNSUPPORTED_CODES || !usedTokenAuth) {
|
||||
Log.w("http", "$method failed: server code ${failure.code} \"${failure.message}\"")
|
||||
throw failure
|
||||
}
|
||||
Log.w("http", "$method: token auth unsupported (server code ${failure.code}), retrying with plaintext")
|
||||
disableTokenAuth()
|
||||
execute(method, params, body)
|
||||
}
|
||||
@@ -76,7 +80,6 @@ class SubsonicClient(
|
||||
Request
|
||||
.Builder()
|
||||
.url(uri(method, params))
|
||||
.header("User-Agent", userAgent)
|
||||
.build()
|
||||
http.newCall(request).execute().use { response ->
|
||||
if (!response.isSuccessful) {
|
||||
|
||||
@@ -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
|
||||
@@ -59,16 +59,24 @@ class SyncManager(
|
||||
return
|
||||
}
|
||||
_status.value = SyncStatus.Running
|
||||
val startedAt = System.nanoTime()
|
||||
val result =
|
||||
try {
|
||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||
Log.i("sync", "started (source ${source.id})")
|
||||
serverActions.flush()
|
||||
SyncService(db, source).sync()
|
||||
val summary = SyncService(db, source).sync()
|
||||
Log.i(
|
||||
"sync",
|
||||
"finished in ${elapsedMs(startedAt)}ms: " +
|
||||
"${summary.artists} artists, ${summary.albums} albums, ${summary.songs} songs, " +
|
||||
"${summary.playlists} playlists, ${summary.playlistSongs} playlist tracks, ${summary.pruned} pruned",
|
||||
)
|
||||
SyncStatus.Success
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
Log.w(TAG, "Sync failed", failure)
|
||||
Log.w("sync", "failed after ${elapsedMs(startedAt)}ms", failure)
|
||||
val message =
|
||||
when (failure) {
|
||||
is NoServerException -> {
|
||||
@@ -87,9 +95,7 @@ class SyncManager(
|
||||
_status.value = result
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val TAG = "SubtracksSync"
|
||||
}
|
||||
private fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000
|
||||
}
|
||||
|
||||
private class NoServerException : IllegalStateException()
|
||||
|
||||
@@ -20,13 +20,28 @@ class SyncService(
|
||||
private val db: SubtracksDatabase,
|
||||
private val source: MusicSource,
|
||||
) {
|
||||
suspend fun sync() {
|
||||
private var artistsSynced = 0
|
||||
private var albumsSynced = 0
|
||||
private var songsSynced = 0
|
||||
private var playlistsSynced = 0
|
||||
private var playlistSongsSynced = 0
|
||||
private var pruned = 0
|
||||
|
||||
suspend fun sync(): SyncSummary {
|
||||
syncArtists()
|
||||
syncAlbums()
|
||||
syncSongs()
|
||||
aggregatePlayData()
|
||||
syncPlaylists()
|
||||
syncPlaylistSongs()
|
||||
return SyncSummary(
|
||||
artists = artistsSynced,
|
||||
albums = albumsSynced,
|
||||
songs = songsSynced,
|
||||
playlists = playlistsSynced,
|
||||
playlistSongs = playlistSongsSynced,
|
||||
pruned = pruned,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun syncArtists() {
|
||||
@@ -36,8 +51,9 @@ class SyncService(
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
artistsSynced += batch.size
|
||||
}
|
||||
pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||
pruned += pruneStaleIds(seen, { library.artistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteArtists(source.id, it) })
|
||||
}
|
||||
|
||||
private suspend fun syncAlbums() {
|
||||
@@ -53,11 +69,13 @@ class SyncService(
|
||||
writeAlbumsAndDiscs(batch, discs)
|
||||
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
||||
albumsSynced += batch.size
|
||||
}
|
||||
pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||
deleteStaleDiscs(it)
|
||||
}
|
||||
pruned += pruneStaleIds(seenAlbums, { library.albumIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteAlbums(source.id, it) })
|
||||
pruned +=
|
||||
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||
deleteStaleDiscs(it)
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun syncSongs() {
|
||||
@@ -67,8 +85,9 @@ class SyncService(
|
||||
if (batch.isEmpty()) return@collect
|
||||
writeSongs(batch)
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
songsSynced += batch.size
|
||||
}
|
||||
pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
||||
pruned += pruneStaleIds(seen, { library.songIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deleteSongs(source.id, it) })
|
||||
}
|
||||
|
||||
private suspend fun writeSongs(batch: List<Song>) {
|
||||
@@ -106,8 +125,9 @@ class SyncService(
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
||||
batch.forEach { seen.add(idHash(it.id)) }
|
||||
playlistsSynced += batch.size
|
||||
}
|
||||
pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
||||
pruned += pruneStaleIds(seen, { library.playlistIdsAfter(source.id, it, PRUNE_PAGE) }, { library.deletePlaylists(source.id, it) })
|
||||
}
|
||||
|
||||
private suspend fun syncPlaylistSongs() {
|
||||
@@ -118,6 +138,7 @@ class SyncService(
|
||||
if (batch.isEmpty()) return@collect
|
||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||
playlistSongsSynced += batch.size
|
||||
}
|
||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
||||
library.deleteOrphanPlaylistSongs(source.id)
|
||||
@@ -173,15 +194,17 @@ class SyncService(
|
||||
seen: HashedIds,
|
||||
pageOf: suspend (afterId: String) -> List<String>,
|
||||
delete: suspend (Collection<String>) -> Unit,
|
||||
) {
|
||||
): Int {
|
||||
seen.freeze()
|
||||
var afterId = ""
|
||||
var pruned = 0
|
||||
while (true) {
|
||||
val ids = pageOf(afterId)
|
||||
if (ids.isEmpty()) return
|
||||
if (ids.isEmpty()) return pruned
|
||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||
if (ids.size < PRUNE_PAGE) return
|
||||
pruned += stale.size
|
||||
if (ids.size < PRUNE_PAGE) return pruned
|
||||
afterId = ids.last()
|
||||
}
|
||||
}
|
||||
@@ -190,22 +213,33 @@ class SyncService(
|
||||
seen: HashedIds,
|
||||
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
||||
delete: suspend (Collection<DiscKey>) -> Unit,
|
||||
) {
|
||||
): Int {
|
||||
seen.freeze()
|
||||
var afterAlbumId = ""
|
||||
var afterDisc = 0L
|
||||
var pruned = 0
|
||||
while (true) {
|
||||
val keys = pageOf(afterAlbumId, afterDisc)
|
||||
if (keys.isEmpty()) return
|
||||
if (keys.isEmpty()) return pruned
|
||||
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
||||
if (keys.size < PRUNE_PAGE) return
|
||||
pruned += stale.size
|
||||
if (keys.size < PRUNE_PAGE) return pruned
|
||||
afterAlbumId = keys.last().albumId
|
||||
afterDisc = keys.last().disc
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
data class SyncSummary(
|
||||
val artists: Int,
|
||||
val albums: Int,
|
||||
val songs: Int,
|
||||
val playlists: Int,
|
||||
val playlistSongs: Int,
|
||||
val pruned: Int,
|
||||
)
|
||||
|
||||
private const val DELETE_CHUNK = 500
|
||||
private const val PRUNE_PAGE = 500
|
||||
private const val INITIAL_HASH_CAPACITY = 256
|
||||
|
||||
@@ -28,6 +28,7 @@ import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.log.Log
|
||||
import com.subtracks.playback.MediaSessionConnection
|
||||
import com.subtracks.playback.PlaybackController
|
||||
import com.subtracks.playback.PlayerConnection
|
||||
@@ -48,6 +49,8 @@ import com.subtracks.ui.settings.SettingsViewModel
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import okhttp3.OkHttpClient
|
||||
import org.koin.core.module.dsl.viewModel
|
||||
import org.koin.core.qualifier.named
|
||||
@@ -68,6 +71,7 @@ fun appModule(
|
||||
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
||||
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
||||
single(createdAtStart = true) { get<UserPreferences>().verboseLogging().onEach(Log::setVerbose).launchIn(get()) }
|
||||
single {
|
||||
SourceRepository(
|
||||
get(),
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
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 defaultFileLevel = LogLevel.INFO
|
||||
private var crashHandlerInstalled = false
|
||||
|
||||
fun init(context: Context) {
|
||||
val app = context.applicationContext
|
||||
appContext = app
|
||||
consoleLevel = if (BuildConfig.DEBUG) LogLevel.VERBOSE else LogLevel.INFO
|
||||
defaultFileLevel = if (BuildConfig.DEBUG) LogLevel.DEBUG else LogLevel.INFO
|
||||
fileStore?.shutdown()
|
||||
fileStore = LogFileStore(File(app.filesDir, "logs"), defaultFileLevel)
|
||||
installCrashHandler()
|
||||
i("app", "started ${BuildConfig.VERSION_NAME} (${BuildConfig.VERSION_CODE}) on Android ${Build.VERSION.SDK_INT} ${Build.MODEL}")
|
||||
}
|
||||
|
||||
fun setVerbose(enabled: Boolean) {
|
||||
fileStore?.setMinLevel(if (enabled) LogLevel.VERBOSE else defaultFileLevel)
|
||||
}
|
||||
|
||||
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 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()
|
||||
v("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) {
|
||||
if (chain.call().isCanceled()) {
|
||||
v("http", "${request.method} ${request.url} cancelled")
|
||||
} else {
|
||||
w("http", "${request.method} ${request.url} failed", failure)
|
||||
}
|
||||
throw failure
|
||||
}
|
||||
}
|
||||
|
||||
fun sql(execution: SqlExecution) {
|
||||
val failure = execution.error
|
||||
if (failure != null) {
|
||||
if (isUpsertConflict(execution.sql, failure)) {
|
||||
v("sql", "upsert fallback conflict: ${shortSql(execution.sql)}")
|
||||
} else {
|
||||
e("sql", "failed: ${shortSql(execution.sql)}", failure)
|
||||
}
|
||||
return
|
||||
}
|
||||
if (!isSlowQuery(execution, SLOW_QUERY_MS)) return
|
||||
val rows = if (isReadStatement(execution.sql)) " rows=${execution.rows}" else ""
|
||||
w("sql", "slow ${execution.durationMillis.roundToLong()}ms$rows: ${shortSql(execution.sql)}")
|
||||
}
|
||||
|
||||
private fun log(
|
||||
level: LogLevel,
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable?,
|
||||
) {
|
||||
val toConsole = level.ordinal >= consoleLevel.ordinal
|
||||
val store = fileStore
|
||||
if (!toConsole && store?.willLog(level) != true) return
|
||||
val redacted = redactLogs(message)
|
||||
if (toConsole) {
|
||||
// Logging is best effort: a logcat write must never disturb the caller.
|
||||
runCatching {
|
||||
val text =
|
||||
if (throwable != null) {
|
||||
"$redacted\n${redactLogs(AndroidLog.getStackTraceString(throwable))}"
|
||||
} else {
|
||||
redacted
|
||||
}
|
||||
AndroidLog.println(priority(level), tag.take(23), text)
|
||||
}
|
||||
}
|
||||
store?.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 val READ_KEYWORDS = setOf("SELECT", "WITH")
|
||||
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 isReadStatement(sql: String): Boolean = sql.trimStart().takeWhile { !it.isWhitespace() }.uppercase() in READ_KEYWORDS
|
||||
|
||||
internal fun isSlowQuery(
|
||||
execution: SqlExecution,
|
||||
thresholdMs: Double,
|
||||
): Boolean = execution.error == null && execution.durationMillis >= thresholdMs && isDataStatement(execution.sql)
|
||||
|
||||
// Room's EntityUpsertAdapter implements @Upsert as an INSERT and treats a uniqueness conflict as the
|
||||
// signal to UPDATE instead (EntityUpsertAdapter.checkUniquenessException). Only downgrade a failed
|
||||
// INSERT whose error says the conflict was a unique/primary key; anything else stays an ERROR so a
|
||||
// genuine failure isn't hidden in release.
|
||||
private const val CONSTRAINT_UNIQUE_MESSAGE = "unique constraint failed"
|
||||
private const val CONSTRAINT_PRIMARY_KEY_MESSAGE = "primary key constraint failed"
|
||||
|
||||
internal fun isUpsertConflict(
|
||||
sql: String,
|
||||
error: Throwable,
|
||||
): Boolean {
|
||||
if (!sql.trimStart().uppercase().startsWith("INSERT")) return false
|
||||
val message = error.message?.lowercase() ?: return false
|
||||
return message.contains(CONSTRAINT_UNIQUE_MESSAGE) || message.contains(CONSTRAINT_PRIMARY_KEY_MESSAGE)
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
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.concurrent.atomic.AtomicInteger
|
||||
import java.util.zip.ZipEntry
|
||||
import java.util.zip.ZipOutputStream
|
||||
|
||||
enum class LogLevel { VERBOSE, DEBUG, INFO, WARN, ERROR }
|
||||
|
||||
class LogFileStore(
|
||||
private val rootDir: File,
|
||||
@Volatile private var 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 pending = AtomicInteger()
|
||||
private val dateFormat = SimpleDateFormat("yyyy-MM-dd", Locale.US)
|
||||
private val lineFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.US)
|
||||
private var current = currentFile()
|
||||
|
||||
init {
|
||||
// Log.init runs in Application.onCreate, so clean up leftovers on the writer thread.
|
||||
runCatching { executor.execute { synchronized(lock) { prune() } } }
|
||||
}
|
||||
|
||||
fun willLog(level: LogLevel): Boolean = level.ordinal >= minLevel.ordinal
|
||||
|
||||
fun write(
|
||||
level: LogLevel,
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable?,
|
||||
) {
|
||||
if (level.ordinal < minLevel.ordinal) return
|
||||
// Drop under pressure rather than grow the queue without bound (verbose logs every request).
|
||||
if (pending.get() >= MAX_PENDING) return
|
||||
pending.incrementAndGet()
|
||||
runCatching {
|
||||
executor.execute {
|
||||
try {
|
||||
synchronized(lock) { append(level, tag, message, throwable) }
|
||||
} finally {
|
||||
pending.decrementAndGet()
|
||||
}
|
||||
}
|
||||
}.onFailure { pending.decrementAndGet() }
|
||||
}
|
||||
|
||||
fun writeImmediately(
|
||||
level: LogLevel,
|
||||
tag: String,
|
||||
message: String,
|
||||
throwable: Throwable?,
|
||||
) {
|
||||
if (level.ordinal < minLevel.ordinal) return
|
||||
runCatching { synchronized(lock) { append(level, tag, message, throwable) } }
|
||||
}
|
||||
|
||||
fun flush() {
|
||||
runCatching { executor.submit {}.get() }
|
||||
}
|
||||
|
||||
fun setMinLevel(level: LogLevel) {
|
||||
minLevel = level
|
||||
}
|
||||
|
||||
fun shutdown() {
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
fun files(): List<File> =
|
||||
rootDir
|
||||
.listFiles { file -> file.isFile && file.name.endsWith(LOG_SUFFIX) }
|
||||
?.sortedByDescending { it.lastModified() }
|
||||
.orEmpty()
|
||||
|
||||
fun exportZip(destDir: File): File? =
|
||||
synchronized(lock) {
|
||||
val logs = files()
|
||||
if (logs.isEmpty()) return null
|
||||
destDir.mkdirs()
|
||||
destDir.listFiles()?.forEach { if (it.isFile) it.delete() }
|
||||
val zip = File(destDir, "subtracks-logs-${System.currentTimeMillis()}.zip")
|
||||
ZipOutputStream(zip.outputStream().buffered()).use { out ->
|
||||
logs.forEach { log ->
|
||||
if (!log.isFile) return@forEach
|
||||
out.putNextEntry(ZipEntry(log.name))
|
||||
log.inputStream().use { it.copyTo(out) }
|
||||
out.closeEntry()
|
||||
}
|
||||
}
|
||||
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()
|
||||
var rolled = false
|
||||
val today = currentFile()
|
||||
if (current.name != today.name) {
|
||||
current = today
|
||||
rolled = true
|
||||
}
|
||||
if (current.length() >= maxFileBytes) {
|
||||
roll()
|
||||
rolled = true
|
||||
}
|
||||
current.appendText(redactLogs(text))
|
||||
if (rolled) 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() {
|
||||
// Never prune the file we are writing to, and never keep fewer than one rolled file slot.
|
||||
val rolled = files().filterNot { it == current }
|
||||
if (rolled.size > maxFiles - 1) rolled.drop(maxFiles - 1).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 MAX_PENDING = 4096
|
||||
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,14 +112,19 @@ 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) position=${controller.currentPosition} duration=${duration(
|
||||
controller.duration,
|
||||
)} state=${controller.playbackState}",
|
||||
)
|
||||
controller.seekTo(index, 0)
|
||||
}
|
||||
|
||||
override fun seekTo(positionMs: Long) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
||||
"playback",
|
||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} duration=${duration(controller.duration)} " +
|
||||
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||
)
|
||||
controller.seekTo(positionMs)
|
||||
@@ -136,7 +141,7 @@ class Media3PlayerHandle(
|
||||
mediaItem: MediaItem?,
|
||||
reason: Int,
|
||||
) {
|
||||
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
||||
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
||||
listener.onTransition()
|
||||
}
|
||||
|
||||
@@ -147,8 +152,9 @@ class Media3PlayerHandle(
|
||||
|
||||
override fun onPlayerError(error: PlaybackException) {
|
||||
Log.w(
|
||||
TAG,
|
||||
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
||||
"playback",
|
||||
"error ${error.errorCodeName} ${describe(controller.currentMediaItem)} " +
|
||||
"position=${controller.currentPosition} duration=${duration(controller.duration)}",
|
||||
error,
|
||||
)
|
||||
listener.onError(playbackErrorMessage(error))
|
||||
@@ -156,15 +162,54 @@ class Media3PlayerHandle(
|
||||
|
||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||
Log.d(
|
||||
TAG,
|
||||
"playback",
|
||||
"state=$playbackState position=${controller.currentPosition} " +
|
||||
"playerDuration=${controller.duration} itemDuration=${currentItem?.durationMs}",
|
||||
"duration=${duration(controller.duration)} itemDuration=${currentItem?.durationMs}",
|
||||
)
|
||||
if (playbackState == Player.STATE_ENDED) {
|
||||
Log.i("playback", "ended ${describe(controller.currentMediaItem)}")
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun describe(mediaItem: MediaItem?): String {
|
||||
if (mediaItem == null) return "item=none"
|
||||
val metadata = mediaItem.mediaMetadata
|
||||
val artist =
|
||||
metadata.artist
|
||||
?.toString()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { " artist=\"$it\"" }
|
||||
.orEmpty()
|
||||
val album =
|
||||
metadata.albumTitle
|
||||
?.toString()
|
||||
?.takeIf { it.isNotBlank() }
|
||||
?.let { " album=\"$it\"" }
|
||||
.orEmpty()
|
||||
val source =
|
||||
when (mediaItem.localConfiguration?.uri?.scheme) {
|
||||
"file", "content" -> "file"
|
||||
"http", "https" -> "http"
|
||||
null -> "none"
|
||||
else -> "other"
|
||||
}
|
||||
return "item id=${mediaItem.mediaId} title=\"${metadata.title?.toString().orEmpty()}\"$artist$album source=$source"
|
||||
}
|
||||
|
||||
private fun duration(value: Long): String = if (value == C.TIME_UNSET) "unset" else "${value}ms"
|
||||
|
||||
private fun transitionReason(reason: Int): String =
|
||||
when (reason) {
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -> "repeat"
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_AUTO -> "auto"
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_SEEK -> "seek"
|
||||
Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED -> "playlist"
|
||||
else -> reason.toString()
|
||||
}
|
||||
|
||||
private fun toMediaItem(item: QueueItem): MediaItem {
|
||||
val quality = sourceRepository.quality.value
|
||||
return queueMediaItem(
|
||||
@@ -190,10 +235,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"
|
||||
|
||||
@@ -11,6 +11,7 @@ import com.subtracks.data.repo.QueueSnapshot
|
||||
import com.subtracks.data.repo.QueueWindowItem
|
||||
import com.subtracks.data.repo.Shuffle
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -127,7 +128,9 @@ class PlaybackController(
|
||||
private var positionJob: Job? = null
|
||||
private var windowJob: Job? = null
|
||||
private var bufferingJob: Job? = null
|
||||
private var stallJob: Job? = null
|
||||
private var showBuffering = false
|
||||
private var stalled = false
|
||||
private val startLock = Mutex()
|
||||
|
||||
private var snapshot: QueueSnapshot? = null
|
||||
@@ -1047,6 +1050,9 @@ class PlaybackController(
|
||||
windowJob?.cancel()
|
||||
bufferingJob?.cancel()
|
||||
bufferingJob = null
|
||||
stallJob?.cancel()
|
||||
stallJob = null
|
||||
stalled = false
|
||||
showBuffering = false
|
||||
snapshot = null
|
||||
queueSourceId = null
|
||||
@@ -1460,21 +1466,42 @@ class PlaybackController(
|
||||
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
||||
|
||||
private fun updateBuffering() {
|
||||
val player = player
|
||||
if (player?.isBuffering != true) {
|
||||
bufferingJob?.cancel()
|
||||
bufferingJob = null
|
||||
stallJob?.cancel()
|
||||
stallJob = null
|
||||
stalled = false
|
||||
showBuffering = false
|
||||
return
|
||||
}
|
||||
if (showBuffering || bufferingJob?.isActive == true) return
|
||||
bufferingJob =
|
||||
scope.launch {
|
||||
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||
if (player?.isBuffering == true) {
|
||||
showBuffering = true
|
||||
refresh()
|
||||
if (!showBuffering && bufferingJob?.isActive != true) {
|
||||
bufferingJob =
|
||||
scope.launch {
|
||||
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||
if (player.isBuffering) {
|
||||
showBuffering = true
|
||||
refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// A stall is buffering that persists long enough to be a real problem, not a track change.
|
||||
if (stallJob?.isActive != true && !stalled) {
|
||||
stallJob =
|
||||
scope.launch {
|
||||
delay(STALL_WATCHDOG_MS)
|
||||
if (player === this@PlaybackController.player && player.isBuffering && player.playWhenReady) {
|
||||
stalled = true
|
||||
val item = player.currentItem
|
||||
Log.w(
|
||||
"playback",
|
||||
"stalled: buffering for ${STALL_WATCHDOG_MS / 1000}s at ${player.currentPositionMs}ms " +
|
||||
"item id=${item?.id} title=\"${item?.title}\"",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun refresh(position: Long? = currentPosition()) {
|
||||
@@ -1541,6 +1568,7 @@ class PlaybackController(
|
||||
const val POSITION_TICK_MS = 500L
|
||||
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||
const val STALL_WATCHDOG_MS = 15_000L
|
||||
const val QUEUE_WINDOW_RADIUS = 25L
|
||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||
const val RESTART_THRESHOLD_MS = 3_000L
|
||||
|
||||
@@ -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
|
||||
@@ -29,9 +28,11 @@ import com.google.common.util.concurrent.Futures
|
||||
import com.google.common.util.concurrent.ListenableFuture
|
||||
import com.subtracks.MainActivity
|
||||
import com.subtracks.R
|
||||
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
|
||||
@@ -104,7 +105,9 @@ class PlaybackService : MediaSessionService() {
|
||||
.setMediaSourceFactory(
|
||||
DefaultMediaSourceFactory(this)
|
||||
.setEnableClippingInMediaPeriod(true)
|
||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
||||
.setDataSourceFactory(
|
||||
mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value)),
|
||||
),
|
||||
).build()
|
||||
exoPlayer.addListener(
|
||||
object : Player.Listener {
|
||||
@@ -183,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",
|
||||
)
|
||||
@@ -208,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.playback
|
||||
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -128,22 +129,28 @@ class Scrobbler(
|
||||
private suspend fun submit(event: Scrobble) {
|
||||
when (event) {
|
||||
is Scrobble.NowPlaying -> {
|
||||
attempt { sink.nowPlaying(event.songId) }
|
||||
Log.i("scrobble", "now playing ${event.songId}")
|
||||
attempt("now playing ${event.songId}") { sink.nowPlaying(event.songId) }
|
||||
}
|
||||
|
||||
is Scrobble.Submission -> {
|
||||
attempt { recordPlay(event.songId, event.time / 1000) }
|
||||
attempt { sink.scrobble(event.songId, event.time) }
|
||||
Log.i("scrobble", "submitting ${event.songId}")
|
||||
attempt("record play ${event.songId}") { recordPlay(event.songId, event.time / 1000) }
|
||||
attempt("scrobble ${event.songId}") { sink.scrobble(event.songId, event.time) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun attempt(block: suspend () -> Unit) {
|
||||
private suspend fun attempt(
|
||||
label: String,
|
||||
block: suspend () -> Unit,
|
||||
) {
|
||||
try {
|
||||
block()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (_: Exception) {
|
||||
} catch (failure: Exception) {
|
||||
Log.w("scrobble", "$label failed", failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -87,9 +87,11 @@ fun CoverArt(
|
||||
colorFilter: ColorFilter? = null,
|
||||
) {
|
||||
val context = LocalPlatformContext.current
|
||||
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
||||
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
||||
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
|
||||
val refKey = ref?.cacheKey
|
||||
val thumbnailKey = thumbnailRef?.cacheKey
|
||||
var failed by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailLoaded by remember(refKey, thumbnailKey) { mutableStateOf(false) }
|
||||
var thumbnailRatio by remember(refKey, thumbnailKey) { mutableStateOf(thumbnailKey?.let(ArtworkRatioCache::get)) }
|
||||
val letterScheme =
|
||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
||||
@@ -102,7 +104,7 @@ fun CoverArt(
|
||||
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||
|
||||
if (square) {
|
||||
val model = remember(ref, thumbnailRef) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
||||
val model = remember(refKey, thumbnailKey) { ref?.let { imageRequest(context, it, crossfade = true) } }
|
||||
Box(modifier.then(frameModifier)) {
|
||||
CoverArtContent(
|
||||
ref = ref,
|
||||
@@ -147,7 +149,7 @@ fun CoverArt(
|
||||
val painter =
|
||||
rememberAsyncImagePainter(
|
||||
model =
|
||||
remember(ref, thumbnailRef, target) {
|
||||
remember(refKey, thumbnailKey, target) {
|
||||
ref?.let {
|
||||
imageRequest(
|
||||
context,
|
||||
@@ -231,7 +233,7 @@ private fun BoxScope.CoverArtContent(
|
||||
}
|
||||
if (thumbnailRef != null && thumbnailRef != ref) {
|
||||
AsyncImage(
|
||||
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
|
||||
model = remember(thumbnailRef.cacheKey) { imageRequest(context, thumbnailRef, crossfade = false) },
|
||||
contentDescription = null,
|
||||
contentScale = contentScale,
|
||||
colorFilter = colorFilter,
|
||||
|
||||
@@ -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,9 @@ fun SettingsRoute(
|
||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||
val context = LocalContext.current
|
||||
val scope = rememberCoroutineScope()
|
||||
SettingsScreen(
|
||||
sources = sources,
|
||||
activeSourceId = activeSourceId,
|
||||
@@ -105,6 +116,7 @@ fun SettingsRoute(
|
||||
downloadOverMetered = downloadOverMetered,
|
||||
scrobbling = scrobbling,
|
||||
offline = offline,
|
||||
verboseLogging = verboseLogging,
|
||||
onSelectSource = viewModel::selectSource,
|
||||
onEditServer = onEditServer,
|
||||
onWifiQualityChange = viewModel::setWifiQuality,
|
||||
@@ -114,9 +126,27 @@ fun SettingsRoute(
|
||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
onOfflineChange = viewModel::setOfflineMode,
|
||||
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||
onAddServer = onAddServer,
|
||||
onOpenDownloads = onOpenDownloads,
|
||||
onOpenLicenses = onOpenLicenses,
|
||||
onShareLogs = {
|
||||
scope.launch {
|
||||
val result =
|
||||
runCatching {
|
||||
val zip = withContext(Dispatchers.IO) { Log.exportZip() }
|
||||
if (zip != null) Log.share(context, zip)
|
||||
zip
|
||||
}
|
||||
val message =
|
||||
when {
|
||||
result.isFailure -> R.string.settings_about_share_logs_failed
|
||||
result.getOrNull() == null -> R.string.settings_about_share_logs_empty
|
||||
else -> null
|
||||
}
|
||||
message?.let { Toast.makeText(context, it, Toast.LENGTH_SHORT).show() }
|
||||
}
|
||||
},
|
||||
onBack = onBack,
|
||||
dismissals = dismissals,
|
||||
)
|
||||
@@ -135,6 +165,7 @@ fun SettingsScreen(
|
||||
scrobbling: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
offline: Boolean = false,
|
||||
verboseLogging: Boolean = false,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
@@ -144,9 +175,11 @@ fun SettingsScreen(
|
||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
onOfflineChange: (Boolean) -> Unit = {},
|
||||
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||
onAddServer: () -> Unit,
|
||||
onOpenDownloads: () -> Unit,
|
||||
onOpenLicenses: () -> Unit,
|
||||
onShareLogs: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
dismissals: Flow<Unit> = emptyFlow(),
|
||||
) {
|
||||
@@ -321,6 +354,35 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_troubleshooting_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_about_actions_share_logs)) },
|
||||
leadingContent = { Icon(Icons.Rounded.Share, contentDescription = null) },
|
||||
modifier = Modifier.clickable(onClick = onShareLogs),
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_about_verbose_logging)) },
|
||||
supportingContent = { Text(stringResource(R.string.settings_about_verbose_logging_description)) },
|
||||
trailingContent = {
|
||||
Switch(
|
||||
checked = verboseLogging,
|
||||
onCheckedChange = onVerboseLoggingChange,
|
||||
colors =
|
||||
SwitchDefaults.colors(
|
||||
uncheckedThumbColor = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
uncheckedTrackColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
uncheckedBorderColor = MaterialTheme.colorScheme.outline,
|
||||
),
|
||||
)
|
||||
},
|
||||
modifier = Modifier.clickable { onVerboseLoggingChange(!verboseLogging) },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_about_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
|
||||
@@ -55,10 +55,19 @@ class SettingsViewModel(
|
||||
|
||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||
|
||||
val verboseLogging: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.verboseLogging()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
fun setOfflineMode(enabled: Boolean) {
|
||||
sourceRepository.setOfflineMode(enabled)
|
||||
}
|
||||
|
||||
fun setVerboseLogging(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setVerboseLogging(enabled) }
|
||||
}
|
||||
|
||||
fun selectSource(id: Long) {
|
||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Pesquisar</string>
|
||||
<string name="settings_about_actions_licenses">Licenças</string>
|
||||
<string name="settings_about_actions_project_homepage">Página do projeto</string>
|
||||
<string name="settings_about_actions_share_logs">Compartilhar logs</string>
|
||||
<string name="settings_about_actions_support">Apoie o desenvolvedor</string>
|
||||
<string name="settings_about_name">Sobre</string>
|
||||
<string name="settings_about_version">Versão %1$s</string>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="search_input_placeholder">搜索</string>
|
||||
<string name="settings_about_actions_licenses">许可</string>
|
||||
<string name="settings_about_actions_project_homepage">项目地址</string>
|
||||
<string name="settings_about_actions_share_logs">分享日志</string>
|
||||
<string name="settings_about_actions_support">支持开发者</string>
|
||||
<string name="settings_about_name">关于</string>
|
||||
<string name="settings_about_version">版本 %1$s</string>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="search_input_placeholder">搜尋</string>
|
||||
<string name="settings_about_actions_licenses">許可</string>
|
||||
<string name="settings_about_actions_project_homepage">專案首頁</string>
|
||||
<string name="settings_about_actions_share_logs">分享日誌</string>
|
||||
<string name="settings_about_actions_support">支援開發者</string>
|
||||
<string name="settings_about_name">關於</string>
|
||||
<string name="settings_about_version">版本 %1$s</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Suche</string>
|
||||
<string name="settings_about_actions_licenses">Lizenzen</string>
|
||||
<string name="settings_about_actions_project_homepage">Projektseite</string>
|
||||
<string name="settings_about_actions_share_logs">Protokolle teilen</string>
|
||||
<string name="settings_about_actions_support">Den Entwickler unterstützen</string>
|
||||
<string name="settings_about_name">Über</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Buscar</string>
|
||||
<string name="settings_about_actions_licenses">Licencias</string>
|
||||
<string name="settings_about_actions_project_homepage">Página de inicio del proyecto</string>
|
||||
<string name="settings_about_actions_share_logs">Compartir registros</string>
|
||||
<string name="settings_about_actions_support">Apoya al desarrollador</string>
|
||||
<string name="settings_about_name">Información</string>
|
||||
<string name="settings_about_version">versión %1$s</string>
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
<string name="search_input_placeholder">Bilatu</string>
|
||||
<string name="settings_about_actions_licenses">Lizentziak</string>
|
||||
<string name="settings_about_actions_project_homepage">Proiektuaren webgunea</string>
|
||||
<string name="settings_about_actions_share_logs">Partekatu erregistroak</string>
|
||||
<string name="settings_about_actions_support">Lagundu garatzaileari</string>
|
||||
<string name="settings_about_name">Honi buruz</string>
|
||||
<string name="settings_about_version">%1$s bertsioa</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Recherche</string>
|
||||
<string name="settings_about_actions_licenses">Licences</string>
|
||||
<string name="settings_about_actions_project_homepage">Page d\'accueil du projet</string>
|
||||
<string name="settings_about_actions_share_logs">Partager les logs</string>
|
||||
<string name="settings_about_actions_support">Soutenir le développeur</string>
|
||||
<string name="settings_about_name">À propos</string>
|
||||
<string name="settings_about_version">version %1$s</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Buscar</string>
|
||||
<string name="settings_about_actions_licenses">Licenzas</string>
|
||||
<string name="settings_about_actions_project_homepage">Web do Proxecto</string>
|
||||
<string name="settings_about_actions_share_logs">Compartir rexistros</string>
|
||||
<string name="settings_about_actions_support">Axuda ao desenvolvemento</string>
|
||||
<string name="settings_about_name">Acerca de</string>
|
||||
<string name="settings_about_version">versión %1$s</string>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<string name="search_input_placeholder">Keresés</string>
|
||||
<string name="settings_about_actions_licenses">Licenszek</string>
|
||||
<string name="settings_about_actions_project_homepage">Projekt weboldal</string>
|
||||
<string name="settings_about_actions_share_logs">Logfájlok megosztása</string>
|
||||
<string name="settings_about_actions_support">Támogasd a fejlesztőt</string>
|
||||
<string name="settings_about_name">Az alkalmazásról</string>
|
||||
<string name="settings_about_version">%1$s verzió</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Ricerca</string>
|
||||
<string name="settings_about_actions_licenses">Licenze</string>
|
||||
<string name="settings_about_actions_project_homepage">Pagina principale del progetto</string>
|
||||
<string name="settings_about_actions_share_logs">Condividi i log</string>
|
||||
<string name="settings_about_actions_support">Sostieni lo sviluppatore</string>
|
||||
<string name="settings_about_name">Informazioni</string>
|
||||
<string name="settings_about_version">versione %1$s</string>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="search_input_placeholder">검색</string>
|
||||
<string name="settings_about_actions_licenses">저작권</string>
|
||||
<string name="settings_about_actions_project_homepage">프로젝트 홈페이지</string>
|
||||
<string name="settings_about_actions_share_logs">로그 공유</string>
|
||||
<string name="settings_about_actions_support">개발자 지원하기</string>
|
||||
<string name="settings_about_name">subtracks에 대하여</string>
|
||||
<string name="settings_about_version">버전: %1$s</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Søk</string>
|
||||
<string name="settings_about_actions_licenses">Lisenser</string>
|
||||
<string name="settings_about_actions_project_homepage">Prosjekthjemmeside</string>
|
||||
<string name="settings_about_actions_share_logs">Dele logger</string>
|
||||
<string name="settings_about_actions_support">Støtt utvikleren</string>
|
||||
<string name="settings_about_name">Om</string>
|
||||
<string name="settings_about_version">versjon %1$s</string>
|
||||
|
||||
@@ -32,6 +32,7 @@
|
||||
<string name="search_input_placeholder">Поиск</string>
|
||||
<string name="settings_about_actions_licenses">Лицензии</string>
|
||||
<string name="settings_about_actions_project_homepage">Сайт проекта</string>
|
||||
<string name="settings_about_actions_share_logs">Поделиться журналами</string>
|
||||
<string name="settings_about_actions_support">Поддержать разработчика</string>
|
||||
<string name="settings_about_name">О Subtracks</string>
|
||||
<string name="settings_about_version">версия %1$s</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Sök</string>
|
||||
<string name="settings_about_actions_licenses">Licenser</string>
|
||||
<string name="settings_about_actions_project_homepage">Projekthemsida</string>
|
||||
<string name="settings_about_actions_share_logs">Dela loggarna</string>
|
||||
<string name="settings_about_actions_support">Stöd utvecklaren</string>
|
||||
<string name="settings_about_name">Om</string>
|
||||
<string name="settings_about_version">version %1$s</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">தேடல்</string>
|
||||
<string name="settings_about_actions_licenses">உரிமங்கள்</string>
|
||||
<string name="settings_about_actions_project_homepage">திட்ட முகப்புப்பக்கம்</string>
|
||||
<string name="settings_about_actions_share_logs">பதிவுகளைப் பகிரவும்</string>
|
||||
<string name="settings_about_actions_support">டெவலப்பரை ஆதரிக்கவும்</string>
|
||||
<string name="settings_about_name">பற்றி</string>
|
||||
<string name="settings_about_version">பதிப்பு %1$s</string>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="search_input_placeholder">ค้นหา</string>
|
||||
<string name="settings_about_actions_licenses">ใบอนุญาต</string>
|
||||
<string name="settings_about_actions_project_homepage">โฮมเพจของโครงการ</string>
|
||||
<string name="settings_about_actions_share_logs">แบ่งปันล็อกไฟล์</string>
|
||||
<string name="settings_about_actions_support">ให้การสนับสนุนผู้พัฒนา</string>
|
||||
<string name="settings_about_name">เกี่ยวกับ</string>
|
||||
<string name="settings_about_version">เวอร์ชั่น %1$s</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Ara</string>
|
||||
<string name="settings_about_actions_licenses">Lisanslar</string>
|
||||
<string name="settings_about_actions_project_homepage">Proje Ana Sayfası</string>
|
||||
<string name="settings_about_actions_share_logs">Günlükleri paylaş</string>
|
||||
<string name="settings_about_actions_support">Geliştiriciyi destekleyin</string>
|
||||
<string name="settings_about_name">Hakkında</string>
|
||||
<string name="settings_about_version">sürüm %1$s</string>
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
<string name="search_input_placeholder">Введіть заголовок/опис</string>
|
||||
<string name="settings_about_actions_licenses">Ліцензії</string>
|
||||
<string name="settings_about_actions_project_homepage">Головна сторінка проєкту</string>
|
||||
<string name="settings_about_actions_share_logs">Поділитися журналами</string>
|
||||
<string name="settings_about_actions_support">Підтримайте розробника</string>
|
||||
<string name="settings_about_name">Про застосунок</string>
|
||||
<string name="settings_about_version">версія %1$s</string>
|
||||
|
||||
@@ -28,6 +28,7 @@
|
||||
<string name="search_input_placeholder">Tìm kiếm</string>
|
||||
<string name="settings_about_actions_licenses">Bản quyền</string>
|
||||
<string name="settings_about_actions_project_homepage">Trang chủ dự án</string>
|
||||
<string name="settings_about_actions_share_logs">Chia sẻ log</string>
|
||||
<string name="settings_about_actions_support">Hỗ trợ nhà phát triển</string>
|
||||
<string name="settings_about_name">Về ứng dụng</string>
|
||||
<string name="settings_about_version">phiên bản %1$s</string>
|
||||
|
||||
@@ -57,8 +57,15 @@
|
||||
|
||||
<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">Share logs</string>
|
||||
<string name="settings_about_actions_support">Support the developer</string>
|
||||
<string name="settings_about_name">About</string>
|
||||
<string name="settings_troubleshooting_name">Troubleshooting</string>
|
||||
<string name="settings_about_share_logs_empty">No logs to send yet</string>
|
||||
<string name="settings_about_share_logs_failed">Couldn\'t prepare the logs</string>
|
||||
<string name="settings_about_share_logs_subject">Subtracks logs</string>
|
||||
<string name="settings_about_verbose_logging">Verbose logging</string>
|
||||
<string name="settings_about_verbose_logging_description">Log extra detail for troubleshooting</string>
|
||||
<string name="settings_about_version">Version %1$s</string>
|
||||
<string name="settings_music_name">Playback</string>
|
||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<cache-path name="log_dumps" path="log_dumps/" />
|
||||
</paths>
|
||||
@@ -0,0 +1,29 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="108dp"
|
||||
android:height="108dp"
|
||||
android:viewportWidth="299.84"
|
||||
android:viewportHeight="219.51">
|
||||
<group android:scaleX="0.43"
|
||||
android:scaleY="0.3147989"
|
||||
android:translateX="85.4544"
|
||||
android:translateY="75.20425">
|
||||
<path
|
||||
android:pathData="m23.84,-0c-12.76,0 -23.44,13 -23.65,25.12 -0.42,79.03 0,160.73 0,170.4 0,12.59 9.8,23.99 23.41,23.99l253.58,0c11.4,0 22.66,-12.38 22.66,-22.79L299.84,24.12c0,-10.8 -10.24,-23.81 -21.84,-23.81 -11.6,0 -254.16,-0.31 -254.16,-0.31zM39.05,25.28c0,0 217.71,0.71 223.67,0.71 6.07,0 12.17,6.83 12.17,12.06l0,142.25c0,8.97 -6.97,14.63 -12.48,14.64l-33.42,0.07c-5.32,0.01 -9.93,-7.19 -11.35,-10.61 -1.89,-4.55 -11.08,-25.75 -12.1,-28.1 -0.6,-1.38 -2.9,-2.83 -5.89,-2.83L98.33,153.45c-2.68,0 -4.89,2.87 -5.67,4.71 -1.12,2.66 -9.02,21.39 -11.63,27.57 -2.73,6.46 -4.37,9.06 -11.47,9.06l-30.97,0c-7.44,0 -13.69,-7.17 -13.67,-15.18l0.28,-139.51c0,-6.88 5.54,-14.83 13.85,-14.83z"
|
||||
android:strokeWidth="0.9"
|
||||
android:fillColor="#FFB300"
|
||||
android:fillType="nonZero"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M90.09,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
|
||||
android:strokeWidth="0.86"
|
||||
android:fillColor="#FFB300"
|
||||
android:fillType="nonZero"
|
||||
android:strokeColor="#00000000"/>
|
||||
<path
|
||||
android:pathData="M209.94,95.03m-32.1,0a32.42,32.1 90,1 1,64.21 0a32.42,32.1 90,1 1,-64.21 0"
|
||||
android:strokeWidth="0.86"
|
||||
android:fillColor="#FFB300"
|
||||
android:fillType="nonZero"
|
||||
android:strokeColor="#00000000"/>
|
||||
</group>
|
||||
</vector>
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 2.2 KiB |
|
After Width: | Height: | Size: 827 B |
|
After Width: | Height: | Size: 1.6 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 3.8 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 2.4 KiB |
|
After Width: | Height: | Size: 7.8 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#1A1200</color>
|
||||
</resources>
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.subtracks
|
||||
|
||||
import android.os.Build
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import okhttp3.Request
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class UserAgentTest {
|
||||
@Test
|
||||
fun theSharedClientSendsTheSubtracksUserAgent() {
|
||||
val server = MockWebServer()
|
||||
server.enqueue(MockResponse())
|
||||
server.start()
|
||||
|
||||
UserAgent
|
||||
.httpClient()
|
||||
.newCall(Request.Builder().url(server.url("/")).build())
|
||||
.execute()
|
||||
.close()
|
||||
val header = server.takeRequest().getHeader("User-Agent")
|
||||
server.shutdown()
|
||||
|
||||
assertEquals("subtracks (Android ${Build.VERSION.RELEASE}; ${BuildConfig.VERSION_NAME})", header)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
package com.subtracks.data.db
|
||||
|
||||
import androidx.room3.migration.Migration
|
||||
import androidx.sqlite.SQLiteConnection
|
||||
import androidx.sqlite.SQLiteDriver
|
||||
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.assertFalse
|
||||
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() {
|
||||
assertTrue(LoggingSQLiteDriver(FakeDriver(hasConnectionPool = true)) {}.hasConnectionPool)
|
||||
assertFalse(LoggingSQLiteDriver(FakeDriver(hasConnectionPool = false)) {}.hasConnectionPool)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recordsOneExecutionPerBatchedWriteRow() {
|
||||
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 { statement ->
|
||||
repeat(3) { index ->
|
||||
statement.bindText(1, "row$index")
|
||||
statement.step()
|
||||
statement.reset()
|
||||
statement.clearBindings()
|
||||
}
|
||||
}
|
||||
}
|
||||
assertEquals(3, executions.count { it.sql.startsWith("INSERT") })
|
||||
}
|
||||
|
||||
@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 doesNotCountIdleTimeBetweenReuses() {
|
||||
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 { statement ->
|
||||
statement.bindText(1, "a")
|
||||
statement.step()
|
||||
statement.reset()
|
||||
Thread.sleep(250)
|
||||
statement.bindText(1, "b")
|
||||
statement.step()
|
||||
statement.reset()
|
||||
}
|
||||
}
|
||||
val inserts = executions.filter { it.sql.startsWith("INSERT") }
|
||||
assertEquals(2, inserts.size)
|
||||
inserts.forEach { assertTrue("${it.durationMillis}ms", it.durationNanos < 100_000_000) }
|
||||
}
|
||||
|
||||
@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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeDriver(
|
||||
override val hasConnectionPool: Boolean,
|
||||
) : SQLiteDriver {
|
||||
override fun open(fileName: String): SQLiteConnection = error("open should not be called")
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
}
|
||||
@@ -173,6 +173,21 @@ class UserPreferencesTest {
|
||||
prefs.setLastSeed(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null))
|
||||
assertEquals(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null), prefs.lastSeed())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun verboseLoggingDefaultsOffAndRoundTrips() =
|
||||
runTest {
|
||||
val file = File.createTempFile("user-prefs", ".preferences_pb").apply { delete() }
|
||||
val store = PreferenceDataStoreFactory.create(scope = backgroundScope) { file }
|
||||
val prefs = UserPreferences(store)
|
||||
|
||||
assertEquals(false, prefs.verboseLogging().first())
|
||||
|
||||
prefs.setVerboseLogging(true)
|
||||
assertEquals(true, prefs.verboseLogging().first())
|
||||
|
||||
file.delete()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
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 redactsCredentialsInThrowable() {
|
||||
val store = LogFileStore(root, LogLevel.VERBOSE)
|
||||
store.write(
|
||||
LogLevel.ERROR,
|
||||
"http",
|
||||
"failed",
|
||||
IllegalStateException("GET http://alice:secret@host/x?u=bob&p=hunter2 failed"),
|
||||
)
|
||||
store.flush()
|
||||
val text = store.files().single().readText()
|
||||
assertTrue(text, text.contains("REDACTED@host"))
|
||||
assertFalse(text, text.contains("secret"))
|
||||
assertFalse(text, text.contains("hunter2"))
|
||||
}
|
||||
|
||||
@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 setMinLevelChangesWhatIsWritten() {
|
||||
val store = LogFileStore(root, LogLevel.WARN)
|
||||
store.setMinLevel(LogLevel.VERBOSE)
|
||||
store.write(LogLevel.VERBOSE, "x", "verbose-line", null)
|
||||
store.setMinLevel(LogLevel.ERROR)
|
||||
store.write(LogLevel.INFO, "x", "info-line", null)
|
||||
store.flush()
|
||||
val text = store.files().single().readText()
|
||||
assertTrue(text, text.contains("verbose-line"))
|
||||
assertFalse(text, text.contains("info-line"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun rotatesWhenFileExceedsCap() {
|
||||
val store = LogFileStore(root, LogLevel.VERBOSE, maxFileBytes = 1)
|
||||
repeat(4) { store.write(LogLevel.INFO, "x", "line $it", null) }
|
||||
store.flush()
|
||||
assertEquals(4, store.files().size)
|
||||
}
|
||||
|
||||
@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"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun classifiesReadStatements() {
|
||||
assertTrue(isReadStatement("select * from songs"))
|
||||
assertTrue(isReadStatement("WITH x AS (SELECT 1) SELECT * FROM x"))
|
||||
assertFalse(isReadStatement("INSERT INTO t VALUES (1)"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun detectsUpsertConflictErrors() {
|
||||
assertTrue(
|
||||
isUpsertConflict(
|
||||
"INSERT INTO playback_cursor (id) VALUES (?)",
|
||||
IllegalStateException("UNIQUE constraint failed: playback_cursor.id"),
|
||||
),
|
||||
)
|
||||
assertTrue(
|
||||
isUpsertConflict(
|
||||
"INSERT OR ABORT INTO t VALUES (?)",
|
||||
IllegalStateException("PRIMARY KEY constraint failed"),
|
||||
),
|
||||
)
|
||||
assertFalse(isUpsertConflict("UPDATE t SET x = 1", IllegalStateException("UNIQUE constraint failed: t.x")))
|
||||
assertFalse(isUpsertConflict("SELECT * FROM t", IllegalStateException("no such table: t")))
|
||||
assertFalse(isUpsertConflict("INSERT INTO t VALUES (?)", IllegalStateException("Error code: 1555")))
|
||||
}
|
||||
|
||||
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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package com.subtracks.ui.components
|
||||
|
||||
import android.content.Context
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.Color
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.test.core.app.ApplicationProvider
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import coil3.ImageLoader
|
||||
import coil3.SingletonImageLoader
|
||||
import coil3.annotation.DelicateCoilApi
|
||||
import coil3.asImage
|
||||
import coil3.test.FakeImageLoaderEngine
|
||||
import com.subtracks.data.model.CoverArtRef
|
||||
import org.junit.After
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Before
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.GraphicsMode
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
|
||||
@OptIn(DelicateCoilApi::class)
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
@GraphicsMode(GraphicsMode.Mode.NATIVE)
|
||||
class CoverArtReloadTest {
|
||||
@get:Rule
|
||||
val composeRule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
private val requests = AtomicInteger(0)
|
||||
|
||||
@Before
|
||||
fun installImageLoader() {
|
||||
SingletonImageLoader.reset()
|
||||
val image = Bitmap.createBitmap(8, 8, Bitmap.Config.ARGB_8888).apply { eraseColor(Color.RED) }.asImage()
|
||||
val engine =
|
||||
FakeImageLoaderEngine
|
||||
.Builder()
|
||||
.requestTransformer {
|
||||
requests.incrementAndGet()
|
||||
it
|
||||
}.default(image)
|
||||
.build()
|
||||
SingletonImageLoader.setUnsafe(
|
||||
ImageLoader
|
||||
.Builder(ApplicationProvider.getApplicationContext<Context>())
|
||||
.components { add(engine) }
|
||||
.build(),
|
||||
)
|
||||
}
|
||||
|
||||
@After
|
||||
fun resetImageLoader() {
|
||||
SingletonImageLoader.reset()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewSaltedUrlForTheSameArtworkDoesNotRestartTheSquareLoad() {
|
||||
assertANewSaltedUrlDoesNotReload(square = true, thumbnail = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewSaltedUrlForTheSameArtworkDoesNotRestartTheNonSquareLoad() {
|
||||
assertANewSaltedUrlDoesNotReload(square = false, thumbnail = false)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun aNewSaltedUrlForTheSameArtworkDoesNotRestartTheThumbnailLoad() {
|
||||
assertANewSaltedUrlDoesNotReload(square = true, thumbnail = true)
|
||||
}
|
||||
|
||||
private fun assertANewSaltedUrlDoesNotReload(
|
||||
square: Boolean,
|
||||
thumbnail: Boolean,
|
||||
) {
|
||||
val refState = mutableStateOf(CoverArtRef(url = "http://host/art?id=1&s=one", cacheKey = "1:art:false"))
|
||||
val thumbnailState: MutableState<CoverArtRef>? =
|
||||
if (thumbnail) mutableStateOf(CoverArtRef(url = "http://host/art?id=1&t=one", cacheKey = "1:art:true")) else null
|
||||
val expected = if (thumbnail) 2 else 1
|
||||
composeRule.setContent {
|
||||
CoverArt(
|
||||
ref = refState.value,
|
||||
name = "Kid A",
|
||||
thumbnailRef = thumbnailState?.value,
|
||||
square = square,
|
||||
modifier = Modifier.size(64.dp),
|
||||
)
|
||||
}
|
||||
composeRule.waitUntil(timeoutMillis = 5_000) { requests.get() == expected }
|
||||
|
||||
refState.value = CoverArtRef(url = "http://host/art?id=1&s=two", cacheKey = "1:art:false")
|
||||
thumbnailState?.value = CoverArtRef(url = "http://host/art?id=1&t=two", cacheKey = "1:art:true")
|
||||
composeRule.waitForIdle()
|
||||
composeRule.mainClock.advanceTimeBy(500)
|
||||
composeRule.waitForIdle()
|
||||
|
||||
assertEquals("a fresh salt for the same artwork must not rebuild the image request", expected, requests.get())
|
||||
}
|
||||
}
|
||||
@@ -67,6 +67,7 @@ class LicensesScreenTest {
|
||||
onAddServer = {},
|
||||
onOpenDownloads = {},
|
||||
onOpenLicenses = { opened = true },
|
||||
onShareLogs = {},
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ org.gradle.parallel=true
|
||||
org.gradle.caching=true
|
||||
org.gradle.configuration-cache=true
|
||||
org.gradle.configuration-cache.problems=warn
|
||||
org.gradle.daemon.idletimeout=600000
|
||||
|
||||
android.useAndroidX=true
|
||||
android.nonTransitiveRClass=true
|
||||
|
||||