Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e396671ac7 | ||
|
|
8e7431cc2e | ||
|
|
7bbc29a1d6 | ||
|
|
0a3e839da0 | ||
|
|
e0a09b724b | ||
|
|
e806eaef89 | ||
|
|
0cef3657bc | ||
|
|
20c6ba526f | ||
|
|
cfb663c250 | ||
|
|
ac1e62fc02 | ||
|
|
adabe21d63 | ||
|
|
0bdac8fa22 | ||
|
|
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 |
@@ -48,6 +48,7 @@ Everything comes from the Nix flake devshell; do not install toolchains by hand.
|
|||||||
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
|
- Networking uses OkHttp and DOM XML parsing; auth uses the Subsonic token scheme by default.
|
||||||
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
|
- Use coroutines and `Flow`; library reads are exposed as `Flow` from Room. Large lists page with Paging 3 over Room `PagingSource` rather than loading the whole table.
|
||||||
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
|
- The library is mirrored in full at sync and must work offline. Never fetch library metadata on demand for browsing, filtering or sorting; only media bytes (streams, downloads) and images are on-demand. A server-derived sort key must be synced into a local column, not requested lazily when the sort is selected.
|
||||||
|
- Auto-sync is a foreground-only, in-process timer (`AutoSync`, driven by the Activity lifecycle), not a background worker: do not move it to WorkManager or an alarm. `SyncManager` coalesces a request that arrives while a sync is running into a single follow-up; the library and Home sync actions no-op while one is running.
|
||||||
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
|
- Playback is Media3: `PlaybackService` is a `MediaSessionService` owning an `ExoPlayer`, and `PlaybackController` (app-scoped Koin singleton) is the only thing the UI talks to. The queue is a `queue_entries` list of references with optional ranges plus a `playback_cursor` row; keep only a bounded window in memory (never the whole queue, and never load a whole table to build one). Stream URLs are freshly salted per request, so attach artwork by `CoverArtRef` cache key, not by URL.
|
||||||
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
|
- UI state lives in `androidx.lifecycle.ViewModel`, wired with Koin (`koinViewModel()`); screens split into a stateful `*Route` and a stateless `*Screen` for screenshot tests.
|
||||||
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
|
- UI and playback preferences go in DataStore (`UserPreferences`), not Room.
|
||||||
|
|||||||
@@ -14,6 +14,19 @@ ktlint {
|
|||||||
version.set(libs.versions.ktlint.get())
|
version.set(libs.versions.ktlint.get())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val commitHash: String =
|
||||||
|
runCatching {
|
||||||
|
providers
|
||||||
|
.exec {
|
||||||
|
commandLine("git", "rev-parse", "HEAD")
|
||||||
|
workingDir = rootProject.layout.projectDirectory.asFile
|
||||||
|
isIgnoreExitValue = true
|
||||||
|
}.standardOutput.asText
|
||||||
|
.get()
|
||||||
|
.trim()
|
||||||
|
.take(7)
|
||||||
|
}.getOrDefault("")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "com.subtracks"
|
namespace = "com.subtracks"
|
||||||
compileSdk = 37
|
compileSdk = 37
|
||||||
@@ -25,18 +38,24 @@ android {
|
|||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 13
|
versionCode = 13
|
||||||
versionName = "3.0.0"
|
versionName = "3.0.0"
|
||||||
|
buildConfigField("String", "COMMIT_HASH", "\"$commitHash\"")
|
||||||
}
|
}
|
||||||
|
|
||||||
buildFeatures {
|
buildFeatures {
|
||||||
compose = true
|
compose = true
|
||||||
buildConfig = true
|
buildConfig = true
|
||||||
resValues = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
androidResources {
|
androidResources {
|
||||||
generateLocaleConfig = true
|
generateLocaleConfig = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
dependenciesInfo {
|
||||||
|
// The APK signing-block copy is encrypted per build, breaking APK reproducibility, and
|
||||||
|
// F-Droid rejects it; the bundle keeps its copy for Play.
|
||||||
|
includeInApk = false
|
||||||
|
}
|
||||||
|
|
||||||
compileOptions {
|
compileOptions {
|
||||||
sourceCompatibility = JavaVersion.VERSION_17
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
targetCompatibility = JavaVersion.VERSION_17
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
@@ -99,10 +118,13 @@ android {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
sourceSets {
|
||||||
|
getByName("debug").res.srcDir("src/nightly/res")
|
||||||
|
}
|
||||||
|
|
||||||
buildTypes {
|
buildTypes {
|
||||||
debug {
|
debug {
|
||||||
applicationIdSuffix = ".nightly"
|
applicationIdSuffix = ".nightly"
|
||||||
resValue("string", "app_name", "subtracks nightly")
|
|
||||||
}
|
}
|
||||||
release {
|
release {
|
||||||
isMinifyEnabled = true
|
isMinifyEnabled = true
|
||||||
@@ -115,7 +137,6 @@ android {
|
|||||||
initWith(getByName("release"))
|
initWith(getByName("release"))
|
||||||
applicationIdSuffix = ".nightly"
|
applicationIdSuffix = ".nightly"
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
resValue("string", "app_name", "subtracks nightly")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -157,10 +178,12 @@ val integrationTestClasses =
|
|||||||
|
|
||||||
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
val demoScreenshotClass = "com.subtracks.ui.DemoScreenshotTest"
|
||||||
|
|
||||||
|
val queryTimingClass = "com.subtracks.data.db.QueryTimingMeasurementTest"
|
||||||
|
|
||||||
tasks.withType<Test>().configureEach {
|
tasks.withType<Test>().configureEach {
|
||||||
if (name.endsWith("UnitTest")) {
|
if (name.endsWith("UnitTest")) {
|
||||||
filter {
|
filter {
|
||||||
(integrationTestClasses + demoScreenshotClass).forEach { excludeTestsMatching(it) }
|
(integrationTestClasses + demoScreenshotClass + queryTimingClass).forEach { excludeTestsMatching(it) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
testLogging {
|
testLogging {
|
||||||
@@ -196,6 +219,20 @@ tasks.register<Test>("demoScreenshots") {
|
|||||||
outputs.upToDateWhen { false }
|
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 {
|
dependencies {
|
||||||
implementation(platform(libs.androidx.compose.bom))
|
implementation(platform(libs.androidx.compose.bom))
|
||||||
implementation(libs.androidx.core.ktx)
|
implementation(libs.androidx.core.ktx)
|
||||||
@@ -221,7 +258,9 @@ dependencies {
|
|||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
implementation(libs.androidx.media3.exoplayer)
|
implementation(libs.androidx.media3.exoplayer)
|
||||||
implementation(libs.androidx.media3.session)
|
implementation(libs.androidx.media3.session)
|
||||||
|
implementation(libs.androidx.media3.datasource)
|
||||||
implementation(libs.androidx.media3.datasource.okhttp)
|
implementation(libs.androidx.media3.datasource.okhttp)
|
||||||
|
implementation(libs.androidx.media3.database)
|
||||||
implementation(libs.okhttp)
|
implementation(libs.okhttp)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.okhttp)
|
implementation(libs.coil.network.okhttp)
|
||||||
|
|||||||
@@ -47,6 +47,16 @@
|
|||||||
<receiver
|
<receiver
|
||||||
android:name=".data.download.DownloadCancelReceiver"
|
android:name=".data.download.DownloadCancelReceiver"
|
||||||
android:exported="false" />
|
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>
|
</application>
|
||||||
|
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge
|
|||||||
import androidx.activity.result.contract.ActivityResultContracts
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
import androidx.activity.viewModels
|
import androidx.activity.viewModels
|
||||||
import androidx.core.content.ContextCompat
|
import androidx.core.content.ContextCompat
|
||||||
|
import com.subtracks.data.sync.AutoSync
|
||||||
import com.subtracks.ui.NowPlayingLauncher
|
import com.subtracks.ui.NowPlayingLauncher
|
||||||
import com.subtracks.ui.SubtracksRoot
|
import com.subtracks.ui.SubtracksRoot
|
||||||
import com.subtracks.ui.theme.SubtracksTheme
|
import com.subtracks.ui.theme.SubtracksTheme
|
||||||
@@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext
|
|||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
||||||
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
||||||
|
private val autoSync: AutoSync by lazy { GlobalContext.get().get() }
|
||||||
|
|
||||||
private val requestNotifications =
|
private val requestNotifications =
|
||||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||||
|
|
||||||
|
override fun onStart() {
|
||||||
|
super.onStart()
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onStop() {
|
||||||
|
super.onStop()
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
requestNotificationPermission()
|
requestNotificationPermission()
|
||||||
|
|||||||
@@ -5,20 +5,28 @@ import coil3.ImageLoader
|
|||||||
import coil3.SingletonImageLoader
|
import coil3.SingletonImageLoader
|
||||||
import coil3.annotation.DelicateCoilApi
|
import coil3.annotation.DelicateCoilApi
|
||||||
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
import coil3.network.okhttp.OkHttpNetworkFetcherFactory
|
||||||
|
import com.subtracks.data.LegacyDataCleanup
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.ArtworkSeedStore
|
import com.subtracks.data.repo.ArtworkSeedStore
|
||||||
import com.subtracks.di.appModule
|
import com.subtracks.di.appModule
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.ui.theme.ArtworkSeedCache
|
import com.subtracks.ui.theme.ArtworkSeedCache
|
||||||
import okhttp3.OkHttpClient
|
|
||||||
import org.koin.core.context.startKoin
|
import org.koin.core.context.startKoin
|
||||||
import org.koin.core.context.stopKoin
|
import org.koin.core.context.stopKoin
|
||||||
|
|
||||||
class SubtracksApp : Application() {
|
class SubtracksApp : Application() {
|
||||||
private val http = OkHttpClient()
|
private val http =
|
||||||
|
UserAgent
|
||||||
|
.httpClient()
|
||||||
|
.newBuilder()
|
||||||
|
.addInterceptor(Log.interceptor())
|
||||||
|
.build()
|
||||||
|
|
||||||
@OptIn(DelicateCoilApi::class)
|
@OptIn(DelicateCoilApi::class)
|
||||||
override fun onCreate() {
|
override fun onCreate() {
|
||||||
super.onCreate()
|
super.onCreate()
|
||||||
|
Log.init(this)
|
||||||
|
LegacyDataCleanup.run(this)
|
||||||
stopKoin()
|
stopKoin()
|
||||||
SingletonImageLoader.reset()
|
SingletonImageLoader.reset()
|
||||||
val koin =
|
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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package com.subtracks.data
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
// One-shot cleanup of the 1.x React Native app, which shipped as com.subtracks before this rewrite.
|
||||||
|
// It left an AsyncStorage database (RKStorage), an older `servers` dump and a downloaded/cached
|
||||||
|
// artwork+audio tree (under the app's external files dir) that v3 never reads. Only a real 1.x upgrade
|
||||||
|
// has those paths, so a fresh install does nothing and an upgrade is cleaned exactly once.
|
||||||
|
//
|
||||||
|
// Delete this file and its call in SubtracksApp once installs can no longer be coming from 1.x.
|
||||||
|
internal object LegacyDataCleanup {
|
||||||
|
fun run(context: Context) {
|
||||||
|
// The cached trees can be large, so keep the walk off the main thread.
|
||||||
|
Thread { run(context.dataDir, context.getExternalFilesDir(null)) }.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun run(
|
||||||
|
dataDir: File,
|
||||||
|
externalFilesDir: File?,
|
||||||
|
) {
|
||||||
|
val database = File(dataDir, "databases/RKStorage")
|
||||||
|
val servers = File(dataDir, "files/servers")
|
||||||
|
val cache = externalFilesDir?.let { File(it, "s") }
|
||||||
|
if (!database.exists() && !servers.exists() && cache?.exists() != true) return
|
||||||
|
deleteDatabase(database)
|
||||||
|
deleteTree(servers)
|
||||||
|
cache?.let(::deleteTree)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteDatabase(database: File) {
|
||||||
|
listOf("", "-wal", "-shm", "-journal").forEach { deleteTree(File(database.path + it)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun deleteTree(target: File) {
|
||||||
|
if (!target.exists()) return
|
||||||
|
val removed = target.deleteRecursively()
|
||||||
|
Log.i("legacy", "removed ${target.path} ($removed)")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,10 +3,14 @@ package com.subtracks.data.db
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
fun createAndroidDatabase(context: Context): SubtracksDatabase =
|
fun createAndroidDatabase(context: Context): SubtracksDatabase {
|
||||||
Room
|
val driver = LoggingSQLiteDriver(BundledSQLiteDriver(), Log::sql)
|
||||||
|
return Room
|
||||||
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
.databaseBuilder<SubtracksDatabase>(context, "subtracks.db")
|
||||||
.setDriver(BundledSQLiteDriver())
|
.setDriver(driver)
|
||||||
.addMigrations(*MIGRATIONS)
|
.addMigrations(*MIGRATIONS.map(::LoggedMigration).toTypedArray())
|
||||||
|
.addCallback(SqliteLifecycleLogging)
|
||||||
.build()
|
.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.Dao
|
||||||
import androidx.room3.Insert
|
import androidx.room3.Insert
|
||||||
|
import androidx.room3.OnConflictStrategy
|
||||||
import androidx.room3.Query
|
import androidx.room3.Query
|
||||||
import androidx.room3.Upsert
|
|
||||||
import com.subtracks.data.model.AlbumSongItem
|
import com.subtracks.data.model.AlbumSongItem
|
||||||
import com.subtracks.data.model.PlaybackCursor
|
import com.subtracks.data.model.PlaybackCursor
|
||||||
import com.subtracks.data.model.PlaylistSongItem
|
import com.subtracks.data.model.PlaylistSongItem
|
||||||
@@ -55,7 +55,7 @@ interface QueueDao {
|
|||||||
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
@Query("SELECT * FROM playback_cursor WHERE id = 1")
|
||||||
suspend fun cursor(): PlaybackCursor?
|
suspend fun cursor(): PlaybackCursor?
|
||||||
|
|
||||||
@Upsert
|
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||||
suspend fun setCursor(cursor: PlaybackCursor)
|
suspend fun setCursor(cursor: PlaybackCursor)
|
||||||
|
|
||||||
@Query(
|
@Query(
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.app.DownloadManager
|
|||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.os.Environment
|
import android.os.Environment
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
|
import com.subtracks.UserAgent
|
||||||
import com.subtracks.data.model.DownloadError
|
import com.subtracks.data.model.DownloadError
|
||||||
|
|
||||||
class SystemDownloadEngine(
|
class SystemDownloadEngine(
|
||||||
@@ -16,6 +17,7 @@ class SystemDownloadEngine(
|
|||||||
.setTitle(request.title)
|
.setTitle(request.title)
|
||||||
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_MUSIC, request.path)
|
||||||
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_HIDDEN)
|
||||||
|
.addRequestHeader("User-Agent", UserAgent.value)
|
||||||
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
// Wi-Fi only unless the preference allows a metered network, and roaming counts as
|
||||||
// one of those; the platform otherwise permits it by default.
|
// one of those; the platform otherwise permits it by default.
|
||||||
.setAllowedOverMetered(request.allowMetered)
|
.setAllowedOverMetered(request.allowMetered)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||||
import androidx.datastore.preferences.core.edit
|
import androidx.datastore.preferences.core.edit
|
||||||
import androidx.datastore.preferences.core.intPreferencesKey
|
import androidx.datastore.preferences.core.intPreferencesKey
|
||||||
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import androidx.datastore.preferences.preferencesDataStore
|
import androidx.datastore.preferences.preferencesDataStore
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
@@ -48,6 +49,10 @@ data class StreamQuality(
|
|||||||
|
|
||||||
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
||||||
|
|
||||||
|
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
|
||||||
|
|
||||||
|
enum class SyncMode { Off, WifiOnly, Always }
|
||||||
|
|
||||||
data class ArtworkSeedValue(
|
data class ArtworkSeedValue(
|
||||||
val cacheKey: String,
|
val cacheKey: String,
|
||||||
val primary: Int,
|
val primary: Int,
|
||||||
@@ -105,6 +110,34 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
||||||
|
|
||||||
|
fun syncMode(): Flow<SyncMode> =
|
||||||
|
store.data.map { prefs ->
|
||||||
|
prefs[syncModeKey]?.let { stored -> SyncMode.entries.firstOrNull { it.name == stored } } ?: SyncMode.WifiOnly
|
||||||
|
}
|
||||||
|
|
||||||
|
suspend fun setSyncMode(mode: SyncMode) {
|
||||||
|
store.edit { prefs -> prefs[syncModeKey] = mode.name }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val syncModeKey = stringPreferencesKey("sync_mode")
|
||||||
|
|
||||||
|
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
||||||
|
|
||||||
|
suspend fun setLastSyncAt(value: Long) {
|
||||||
|
store.edit { prefs -> prefs[lastSyncAtKey] = value }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val lastSyncAtKey = longPreferencesKey("last_sync_at")
|
||||||
|
|
||||||
|
fun syncIntervalMinutes(): Flow<Int> =
|
||||||
|
store.data.map { prefs -> (prefs[syncIntervalKey] ?: DEFAULT_SYNC_INTERVAL_MINUTES).coerceAtLeast(1) }
|
||||||
|
|
||||||
|
suspend fun setSyncIntervalMinutes(value: Int) {
|
||||||
|
store.edit { prefs -> prefs[syncIntervalKey] = value.coerceAtLeast(1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
private val syncIntervalKey = intPreferencesKey("sync_interval_minutes")
|
||||||
|
|
||||||
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
||||||
|
|
||||||
suspend fun setScrobbling(enabled: Boolean) {
|
suspend fun setScrobbling(enabled: Boolean) {
|
||||||
@@ -121,6 +154,14 @@ class UserPreferences(
|
|||||||
|
|
||||||
private val offlineModeKey = booleanPreferencesKey("offline_mode")
|
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? {
|
suspend fun lastSeed(): ArtworkSeedValue? {
|
||||||
val prefs = store.data.first()
|
val prefs = store.data.first()
|
||||||
val cacheKey = prefs[lastSeedKey] ?: return null
|
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.ListDownloadStatus
|
||||||
import com.subtracks.data.model.SongDownload
|
import com.subtracks.data.model.SongDownload
|
||||||
import com.subtracks.data.model.coverArtKey
|
import com.subtracks.data.model.coverArtKey
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import kotlinx.coroutines.CoroutineDispatcher
|
import kotlinx.coroutines.CoroutineDispatcher
|
||||||
@@ -79,7 +80,10 @@ class DownloadRepository(
|
|||||||
}
|
}
|
||||||
scope.launch {
|
scope.launch {
|
||||||
sourceRepository.offline.collect { offline ->
|
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()
|
dir(sourceId).mkdirs()
|
||||||
file(sourceId, songId).delete()
|
file(sourceId, songId).delete()
|
||||||
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
db.downloadDao().upsert(SongDownload(sourceId = sourceId, songId = songId, status = DownloadStatus.Queued))
|
||||||
|
Log.i("download", "queued song=$songId (source $sourceId)")
|
||||||
ensurePolling()
|
ensurePolling()
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
@@ -336,6 +341,7 @@ class DownloadRepository(
|
|||||||
|
|
||||||
private suspend fun removeRows(rows: List<SongDownload>) {
|
private suspend fun removeRows(rows: List<SongDownload>) {
|
||||||
if (rows.isEmpty()) return
|
if (rows.isEmpty()) return
|
||||||
|
Log.i("download", "removed ${rows.size} download(s)")
|
||||||
val engineIds = rows.mapNotNull { it.engineId }
|
val engineIds = rows.mapNotNull { it.engineId }
|
||||||
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
if (engineIds.isNotEmpty()) engine.cancel(engineIds)
|
||||||
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
rows.groupBy { it.sourceId }.forEach { (sourceId, group) ->
|
||||||
@@ -581,6 +587,14 @@ class DownloadRepository(
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
if (updated != row) db.downloadDao().upsert(updated)
|
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(
|
private suspend fun markFailed(
|
||||||
@@ -590,6 +604,7 @@ class DownloadRepository(
|
|||||||
row.engineId?.let { engine.cancel(listOf(it)) }
|
row.engineId?.let { engine.cancel(listOf(it)) }
|
||||||
file(row.sourceId, row.songId).delete()
|
file(row.sourceId, row.songId).delete()
|
||||||
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
db.downloadDao().upsert(row.copy(status = DownloadStatus.Failed, engineId = null, error = error))
|
||||||
|
Log.w("download", "failed ${row.songId}: $error")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun ensurePolling() {
|
private fun ensurePolling() {
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import com.subtracks.data.prefs.PlaylistSort
|
|||||||
import com.subtracks.data.prefs.StarredFilter
|
import com.subtracks.data.prefs.StarredFilter
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -464,14 +465,17 @@ class LibraryRepository(
|
|||||||
}
|
}
|
||||||
val previous = starredValue(sourceId, type, id)
|
val previous = starredValue(sourceId, type, id)
|
||||||
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
updateStarred(sourceId, type, id, if (starred) System.currentTimeMillis() else null)
|
||||||
|
val action = if (starred) "star" else "unstar"
|
||||||
return try {
|
return try {
|
||||||
serverActions.setStar(type, id, starred)
|
serverActions.setStar(type, id, starred)
|
||||||
|
Log.i("star", "$action ${type.name.lowercase()} $id")
|
||||||
Result.success(Unit)
|
Result.success(Unit)
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
withContext(NonCancellable) { updateStarred(sourceId, type, id, previous) }
|
||||||
showMessage(UiMessage(R.string.star_update_failed))
|
showMessage(UiMessage(R.string.star_update_failed))
|
||||||
|
Log.w("star", "failed to $action ${type.name.lowercase()} $id", failure)
|
||||||
Result.failure(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.ServerActionSink
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
@@ -29,8 +30,13 @@ class PendingServerActionSink(
|
|||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
var lastReady: Boolean? = null
|
||||||
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
||||||
.collect { ready ->
|
.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
|
// 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.
|
// flag's default at cold start, so wait for that flag to agree before replaying.
|
||||||
if (ready) {
|
if (ready) {
|
||||||
@@ -96,12 +102,14 @@ class PendingServerActionSink(
|
|||||||
for (action in dao.pending(sourceId)) {
|
for (action in dao.pending(sourceId)) {
|
||||||
try {
|
try {
|
||||||
deliver(action)
|
deliver(action)
|
||||||
|
Log.i("net", "replayed ${action.kind.name.lowercase()} ${action.targetId}")
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
// Still unreachable: keep this and the remaining actions for the next attempt.
|
// Still unreachable: keep this and the remaining actions for the next attempt.
|
||||||
if (isRetryable(failure)) return
|
if (isRetryable(failure)) return
|
||||||
// The server answered and refused; drop the action rather than block the queue.
|
// 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)
|
dao.delete(action.id)
|
||||||
}
|
}
|
||||||
@@ -139,6 +147,7 @@ class PendingServerActionSink(
|
|||||||
db.pendingActionDao().replace(
|
db.pendingActionDao().replace(
|
||||||
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
PendingAction(sourceId = sourceId, kind = kind, targetId = targetId, starType = starType, time = time),
|
||||||
)
|
)
|
||||||
|
Log.i("net", "queued ${kind.name.lowercase()} $targetId")
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ import com.subtracks.data.source.MusicSource
|
|||||||
import com.subtracks.data.source.declaredStreamLength
|
import com.subtracks.data.source.declaredStreamLength
|
||||||
import com.subtracks.data.source.streamLengthSuffix
|
import com.subtracks.data.source.streamLengthSuffix
|
||||||
import com.subtracks.data.source.subsonic.SubsonicClient
|
import com.subtracks.data.source.subsonic.SubsonicClient
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
@@ -46,6 +47,13 @@ class SourceRepository(
|
|||||||
val source: SubsonicMusicSource,
|
val source: SubsonicMusicSource,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private data class ResolvedSource(
|
||||||
|
val config: SubsonicConfig?,
|
||||||
|
val quality: StreamQuality,
|
||||||
|
val mode: NetworkMode,
|
||||||
|
val concurrency: Int,
|
||||||
|
)
|
||||||
|
|
||||||
@Volatile
|
@Volatile
|
||||||
private var active: ActiveSource? = null
|
private var active: ActiveSource? = null
|
||||||
|
|
||||||
@@ -64,6 +72,7 @@ class SourceRepository(
|
|||||||
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
fun downloadsAllowedOverMetered(): Boolean = allowMeteredDownloads
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
|
Log.i("net", "offline mode -> $enabled")
|
||||||
scope.launch { prefs.setOfflineMode(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
|
// 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.
|
// cold start with offline on never touches the network.
|
||||||
_offline.value = prefs.offlineMode().first()
|
_offline.value = prefs.offlineMode().first()
|
||||||
|
var lastMode: NetworkMode? = null
|
||||||
combine(
|
combine(
|
||||||
db.sourcesDao().activeSubsonicConfig(),
|
db.sourcesDao().activeSubsonicConfig(),
|
||||||
networkMode,
|
networkMode,
|
||||||
@@ -88,13 +98,23 @@ class SourceRepository(
|
|||||||
prefs.streamQuality(NetworkMode.Mobile),
|
prefs.streamQuality(NetworkMode.Mobile),
|
||||||
prefs.syncConcurrency(),
|
prefs.syncConcurrency(),
|
||||||
) { config, mode, wifi, mobile, concurrency ->
|
) { config, mode, wifi, mobile, concurrency ->
|
||||||
Triple(config, if (mode == NetworkMode.Wifi) wifi else mobile, concurrency)
|
ResolvedSource(config, if (mode == NetworkMode.Wifi) wifi else mobile, mode, concurrency)
|
||||||
}.collect { (config, quality, concurrency) ->
|
}.collect { (config, quality, mode, concurrency) ->
|
||||||
|
if (mode != lastMode) {
|
||||||
|
Log.i("net", "network mode -> ${mode.key}")
|
||||||
|
lastMode = mode
|
||||||
|
}
|
||||||
val sourceChanged = config?.id != active?.id
|
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)) }
|
active = config?.let { ActiveSource(it.id, it.toMusicSource(quality, concurrency)) }
|
||||||
_quality.value = quality
|
_quality.value = quality
|
||||||
if (sourceChanged && config != null && config.useTokenAuth && !_offline.value) {
|
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.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
|
import com.subtracks.log.Log
|
||||||
import okhttp3.HttpUrl
|
import okhttp3.HttpUrl
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import okhttp3.Request
|
import okhttp3.Request
|
||||||
@@ -20,7 +21,6 @@ class SubsonicClient(
|
|||||||
private val password: String,
|
private val password: String,
|
||||||
useTokenAuth: Boolean,
|
useTokenAuth: Boolean,
|
||||||
private val http: OkHttpClient,
|
private val http: OkHttpClient,
|
||||||
private val userAgent: String = "subtracks/android",
|
|
||||||
private val onTokenAuthUnsupported: () -> Unit = {},
|
private val onTokenAuthUnsupported: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
@Volatile
|
@Volatile
|
||||||
@@ -61,7 +61,11 @@ class SubsonicClient(
|
|||||||
return try {
|
return try {
|
||||||
execute(method, params, body)
|
execute(method, params, body)
|
||||||
} catch (failure: SubsonicException) {
|
} 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()
|
disableTokenAuth()
|
||||||
execute(method, params, body)
|
execute(method, params, body)
|
||||||
}
|
}
|
||||||
@@ -76,7 +80,6 @@ class SubsonicClient(
|
|||||||
Request
|
Request
|
||||||
.Builder()
|
.Builder()
|
||||||
.url(uri(method, params))
|
.url(uri(method, params))
|
||||||
.header("User-Agent", userAgent)
|
|
||||||
.build()
|
.build()
|
||||||
http.newCall(request).execute().use { response ->
|
http.newCall(request).execute().use { response ->
|
||||||
if (!response.isSuccessful) {
|
if (!response.isSuccessful) {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Job
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
internal const val AUTO_SYNC_MAX_POLL_MS = 15L * 60 * 1000
|
||||||
|
|
||||||
|
internal fun shouldAutoSync(
|
||||||
|
mode: SyncMode,
|
||||||
|
network: NetworkMode,
|
||||||
|
offline: Boolean,
|
||||||
|
hasSource: Boolean,
|
||||||
|
lastSyncAt: Long,
|
||||||
|
now: Long,
|
||||||
|
intervalMs: Long,
|
||||||
|
): Boolean {
|
||||||
|
val allowed =
|
||||||
|
when (mode) {
|
||||||
|
SyncMode.Off -> false
|
||||||
|
SyncMode.WifiOnly -> network == NetworkMode.Wifi
|
||||||
|
SyncMode.Always -> true
|
||||||
|
}
|
||||||
|
return allowed && !offline && hasSource && (lastSyncAt > now || now - lastSyncAt >= intervalMs)
|
||||||
|
}
|
||||||
|
|
||||||
|
class AutoSync(
|
||||||
|
private val preferences: UserPreferences,
|
||||||
|
private val networkMode: Flow<NetworkMode>,
|
||||||
|
private val hasSource: suspend () -> Boolean,
|
||||||
|
private val requestSync: () -> Unit,
|
||||||
|
private val scope: CoroutineScope,
|
||||||
|
private val now: () -> Long = System::currentTimeMillis,
|
||||||
|
private val maxPollMs: Long = AUTO_SYNC_MAX_POLL_MS,
|
||||||
|
) {
|
||||||
|
@Volatile
|
||||||
|
private var lastNetwork: NetworkMode? = null
|
||||||
|
private var loop: Job? = null
|
||||||
|
|
||||||
|
init {
|
||||||
|
scope.launch { networkMode.collect { lastNetwork = it } }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setForeground(active: Boolean) {
|
||||||
|
if (active) start() else stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun start() {
|
||||||
|
if (loop?.isActive == true) return
|
||||||
|
loop =
|
||||||
|
scope.launch {
|
||||||
|
while (isActive) {
|
||||||
|
val intervalMs =
|
||||||
|
try {
|
||||||
|
checkAndRequest()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
throw cancellation
|
||||||
|
} catch (failure: Exception) {
|
||||||
|
Log.w("sync", "auto-sync check failed", failure)
|
||||||
|
maxPollMs
|
||||||
|
}
|
||||||
|
delay(intervalMs.coerceAtMost(maxPollMs))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stop() {
|
||||||
|
loop?.cancel()
|
||||||
|
loop = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private suspend fun checkAndRequest(): Long {
|
||||||
|
val mode = preferences.syncMode().first()
|
||||||
|
val network = lastNetwork ?: networkMode.first()
|
||||||
|
val offline = preferences.offlineMode().first()
|
||||||
|
val hasSource = hasSource()
|
||||||
|
val lastSyncAt = preferences.lastSyncAt().first()
|
||||||
|
val intervalMs = preferences.syncIntervalMinutes().first() * 60_000L
|
||||||
|
val time = now()
|
||||||
|
val due =
|
||||||
|
shouldAutoSync(
|
||||||
|
mode = mode,
|
||||||
|
network = network,
|
||||||
|
offline = offline,
|
||||||
|
hasSource = hasSource,
|
||||||
|
lastSyncAt = lastSyncAt,
|
||||||
|
now = time,
|
||||||
|
intervalMs = intervalMs,
|
||||||
|
)
|
||||||
|
Log.d(
|
||||||
|
"sync",
|
||||||
|
"auto-sync due=$due (mode=$mode, network=$network, offline=$offline, hasSource=$hasSource, " +
|
||||||
|
"sinceLast=${time - lastSyncAt}ms, interval=${intervalMs}ms)",
|
||||||
|
)
|
||||||
|
if (due && currentCoroutineContext().isActive) requestSync()
|
||||||
|
return intervalMs
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +1,31 @@
|
|||||||
package com.subtracks.data.sync
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
import android.util.Log
|
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.UiMessage
|
import com.subtracks.UiMessage
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.channels.Channel
|
import kotlinx.coroutines.channels.Channel
|
||||||
|
import kotlinx.coroutines.currentCoroutineContext
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
sealed interface SyncStatus {
|
sealed interface SyncStatus {
|
||||||
data object Idle : SyncStatus
|
data object Idle : SyncStatus
|
||||||
|
|
||||||
data object Running : SyncStatus
|
data class Running(
|
||||||
|
val silent: Boolean,
|
||||||
|
) : SyncStatus
|
||||||
|
|
||||||
data object Success : SyncStatus
|
data object Success : SyncStatus
|
||||||
|
|
||||||
@@ -34,41 +39,65 @@ class SyncManager(
|
|||||||
private val sourceRepository: SourceRepository,
|
private val sourceRepository: SourceRepository,
|
||||||
private val queueRepository: QueueRepository,
|
private val queueRepository: QueueRepository,
|
||||||
private val serverActions: ServerActionSink,
|
private val serverActions: ServerActionSink,
|
||||||
|
private val preferences: UserPreferences,
|
||||||
private val showMessage: (UiMessage) -> Unit = {},
|
private val showMessage: (UiMessage) -> Unit = {},
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
) {
|
) {
|
||||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
private val requests = Channel<Boolean>(Channel.CONFLATED)
|
||||||
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
||||||
val status: StateFlow<SyncStatus> = _status
|
val status: StateFlow<SyncStatus> = _status
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
for (ignored in requests) runSync()
|
for (silent in requests) {
|
||||||
|
try {
|
||||||
|
runSync(silent)
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
if (!currentCoroutineContext().isActive) throw cancellation
|
||||||
|
_status.value = SyncStatus.Idle
|
||||||
|
Log.w("sync", "sync interrupted", cancellation)
|
||||||
|
} catch (failure: Throwable) {
|
||||||
|
_status.value = SyncStatus.Idle
|
||||||
|
Log.w("sync", "unexpected failure", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fun requestSync() {
|
fun requestSync(silent: Boolean = false) {
|
||||||
requests.trySend(Unit)
|
requests.trySend(silent)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun runSync() {
|
private suspend fun runSync(silent: Boolean) {
|
||||||
if (sourceRepository.offline.value) {
|
if (sourceRepository.offline.value) {
|
||||||
val offline = UiMessage(R.string.sync_offline)
|
if (!silent) {
|
||||||
showMessage(offline)
|
val offline = UiMessage(R.string.sync_offline)
|
||||||
_status.value = SyncStatus.Failed(offline)
|
showMessage(offline)
|
||||||
|
_status.value = SyncStatus.Failed(offline)
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_status.value = SyncStatus.Running
|
_status.value = SyncStatus.Running(silent)
|
||||||
|
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
|
||||||
|
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
|
||||||
|
val startedAt = System.nanoTime()
|
||||||
val result =
|
val result =
|
||||||
try {
|
try {
|
||||||
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
val source = sourceRepository.activeMusicSource() ?: throw NoServerException()
|
||||||
|
Log.i("sync", "started (source ${source.id})")
|
||||||
serverActions.flush()
|
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
|
SyncStatus.Success
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (failure: Exception) {
|
} catch (failure: Exception) {
|
||||||
Log.w(TAG, "Sync failed", failure)
|
Log.w("sync", "failed after ${elapsedMs(startedAt)}ms", failure)
|
||||||
val message =
|
val message =
|
||||||
when (failure) {
|
when (failure) {
|
||||||
is NoServerException -> {
|
is NoServerException -> {
|
||||||
@@ -80,16 +109,14 @@ class SyncManager(
|
|||||||
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
showMessage(message)
|
if (!silent) showMessage(message)
|
||||||
SyncStatus.Failed(message)
|
SyncStatus.Failed(message)
|
||||||
}
|
}
|
||||||
queueRepository.invalidateLibraryCache()
|
queueRepository.invalidateLibraryCache()
|
||||||
_status.value = result
|
_status.value = result
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private fun elapsedMs(startNanos: Long): Long = (System.nanoTime() - startNanos) / 1_000_000
|
||||||
const val TAG = "SubtracksSync"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private class NoServerException : IllegalStateException()
|
private class NoServerException : IllegalStateException()
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import android.app.Notification
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.content.Context
|
||||||
|
import android.os.Build
|
||||||
|
import androidx.core.app.NotificationCompat
|
||||||
|
import androidx.core.app.NotificationManagerCompat
|
||||||
|
import com.subtracks.R
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
|
class SyncNotifier(
|
||||||
|
private val context: Context,
|
||||||
|
private val status: Flow<SyncStatus>,
|
||||||
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||||
|
) {
|
||||||
|
private val manager = NotificationManagerCompat.from(context)
|
||||||
|
|
||||||
|
fun start() {
|
||||||
|
createChannel()
|
||||||
|
scope.launch {
|
||||||
|
status.collect { syncStatus ->
|
||||||
|
if (syncStatus is SyncStatus.Running && manager.areNotificationsEnabled()) {
|
||||||
|
manager.notify(ID, build())
|
||||||
|
} else {
|
||||||
|
manager.cancel(ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun build(): Notification =
|
||||||
|
NotificationCompat
|
||||||
|
.Builder(context, CHANNEL_ID)
|
||||||
|
.setSmallIcon(R.drawable.ic_stat_name)
|
||||||
|
.setContentTitle(context.getString(R.string.sync_notification_title))
|
||||||
|
.setContentText(context.getString(R.string.sync_notification_text))
|
||||||
|
.setOnlyAlertOnce(true)
|
||||||
|
.setSilent(true)
|
||||||
|
.setProgress(0, 0, true)
|
||||||
|
.setContentIntent(launchIntent())
|
||||||
|
.build()
|
||||||
|
|
||||||
|
private fun launchIntent(): PendingIntent? =
|
||||||
|
context.packageManager
|
||||||
|
.getLaunchIntentForPackage(context.packageName)
|
||||||
|
?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) }
|
||||||
|
|
||||||
|
private fun createChannel() {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
|
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||||
|
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||||
|
manager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
CHANNEL_ID,
|
||||||
|
context.getString(R.string.sync_notification_channel),
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
).apply { setShowBadge(false) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ID = 2
|
||||||
|
private const val CHANNEL_ID = "sync"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,13 +20,28 @@ class SyncService(
|
|||||||
private val db: SubtracksDatabase,
|
private val db: SubtracksDatabase,
|
||||||
private val source: MusicSource,
|
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()
|
syncArtists()
|
||||||
syncAlbums()
|
syncAlbums()
|
||||||
syncSongs()
|
syncSongs()
|
||||||
aggregatePlayData()
|
aggregatePlayData()
|
||||||
syncPlaylists()
|
syncPlaylists()
|
||||||
syncPlaylistSongs()
|
syncPlaylistSongs()
|
||||||
|
return SyncSummary(
|
||||||
|
artists = artistsSynced,
|
||||||
|
albums = albumsSynced,
|
||||||
|
songs = songsSynced,
|
||||||
|
playlists = playlistsSynced,
|
||||||
|
playlistSongs = playlistSongsSynced,
|
||||||
|
pruned = pruned,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncArtists() {
|
private suspend fun syncArtists() {
|
||||||
@@ -36,8 +51,9 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
write("artists", artistColumns, primaryKey, batch) { it.values() }
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
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() {
|
private suspend fun syncAlbums() {
|
||||||
@@ -53,11 +69,13 @@ class SyncService(
|
|||||||
writeAlbumsAndDiscs(batch, discs)
|
writeAlbumsAndDiscs(batch, discs)
|
||||||
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
batch.forEach { seenAlbums.add(idHash(it.id)) }
|
||||||
discs.forEach { seenDiscs.add(discHash(it.albumId, it.disc)) }
|
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) })
|
pruned += 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) }) {
|
pruned +=
|
||||||
deleteStaleDiscs(it)
|
pruneStaleDiscs(seenDiscs, { albumId, disc -> library.discKeysAfter(source.id, albumId, disc, PRUNE_PAGE) }) {
|
||||||
}
|
deleteStaleDiscs(it)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun syncSongs() {
|
private suspend fun syncSongs() {
|
||||||
@@ -67,8 +85,9 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
writeSongs(batch)
|
writeSongs(batch)
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
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>) {
|
private suspend fun writeSongs(batch: List<Song>) {
|
||||||
@@ -106,8 +125,9 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
write("playlists", playlistColumns, primaryKey, batch) { it.values() }
|
||||||
batch.forEach { seen.add(idHash(it.id)) }
|
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() {
|
private suspend fun syncPlaylistSongs() {
|
||||||
@@ -118,6 +138,7 @@ class SyncService(
|
|||||||
if (batch.isEmpty()) return@collect
|
if (batch.isEmpty()) return@collect
|
||||||
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
write("playlist_songs", playlistSongColumns, playlistSongKey, batch) { it.values() }
|
||||||
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
batch.forEach { counts[it.playlistId] = (counts[it.playlistId] ?: 0L) + 1L }
|
||||||
|
playlistSongsSynced += batch.size
|
||||||
}
|
}
|
||||||
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
playlistIds.forEach { library.deletePlaylistSongsFrom(source.id, it, counts[it] ?: 0L) }
|
||||||
library.deleteOrphanPlaylistSongs(source.id)
|
library.deleteOrphanPlaylistSongs(source.id)
|
||||||
@@ -173,15 +194,17 @@ class SyncService(
|
|||||||
seen: HashedIds,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterId: String) -> List<String>,
|
pageOf: suspend (afterId: String) -> List<String>,
|
||||||
delete: suspend (Collection<String>) -> Unit,
|
delete: suspend (Collection<String>) -> Unit,
|
||||||
) {
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterId = ""
|
var afterId = ""
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val ids = pageOf(afterId)
|
val ids = pageOf(afterId)
|
||||||
if (ids.isEmpty()) return
|
if (ids.isEmpty()) return pruned
|
||||||
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
val stale = ids.filterNot { seen.contains(idHash(it)) }
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(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()
|
afterId = ids.last()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -190,22 +213,33 @@ class SyncService(
|
|||||||
seen: HashedIds,
|
seen: HashedIds,
|
||||||
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
pageOf: suspend (afterAlbumId: String, afterDisc: Long) -> List<DiscKey>,
|
||||||
delete: suspend (Collection<DiscKey>) -> Unit,
|
delete: suspend (Collection<DiscKey>) -> Unit,
|
||||||
) {
|
): Int {
|
||||||
seen.freeze()
|
seen.freeze()
|
||||||
var afterAlbumId = ""
|
var afterAlbumId = ""
|
||||||
var afterDisc = 0L
|
var afterDisc = 0L
|
||||||
|
var pruned = 0
|
||||||
while (true) {
|
while (true) {
|
||||||
val keys = pageOf(afterAlbumId, afterDisc)
|
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)) }
|
val stale = keys.filterNot { seen.contains(discHash(it.albumId, it.disc)) }
|
||||||
stale.chunked(DELETE_CHUNK).forEach { delete(it) }
|
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
|
afterAlbumId = keys.last().albumId
|
||||||
afterDisc = keys.last().disc
|
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 DELETE_CHUNK = 500
|
||||||
private const val PRUNE_PAGE = 500
|
private const val PRUNE_PAGE = 500
|
||||||
private const val INITIAL_HASH_CAPACITY = 256
|
private const val INITIAL_HASH_CAPACITY = 256
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ import com.subtracks.data.repo.PendingServerActionSink
|
|||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
|
import com.subtracks.data.sync.AutoSync
|
||||||
import com.subtracks.data.sync.SyncManager
|
import com.subtracks.data.sync.SyncManager
|
||||||
|
import com.subtracks.data.sync.SyncNotifier
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.playback.MediaSessionConnection
|
import com.subtracks.playback.MediaSessionConnection
|
||||||
import com.subtracks.playback.PlaybackController
|
import com.subtracks.playback.PlaybackController
|
||||||
import com.subtracks.playback.PlayerConnection
|
import com.subtracks.playback.PlayerConnection
|
||||||
@@ -48,6 +51,8 @@ import com.subtracks.ui.settings.SettingsViewModel
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.module.dsl.viewModel
|
import org.koin.core.module.dsl.viewModel
|
||||||
import org.koin.core.qualifier.named
|
import org.koin.core.qualifier.named
|
||||||
@@ -68,6 +73,7 @@ fun appModule(
|
|||||||
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
single { CoroutineScope(SupervisorJob() + Dispatchers.Default) }
|
||||||
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
single(named("io")) { CoroutineScope(SupervisorJob() + Dispatchers.IO) }
|
||||||
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
single(named("playback")) { CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) }
|
||||||
|
single(createdAtStart = true) { get<UserPreferences>().verboseLogging().onEach(Log::setVerbose).launchIn(get()) }
|
||||||
single {
|
single {
|
||||||
SourceRepository(
|
SourceRepository(
|
||||||
get(),
|
get(),
|
||||||
@@ -92,7 +98,16 @@ fun appModule(
|
|||||||
}
|
}
|
||||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
||||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||||
single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) }
|
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||||
|
single {
|
||||||
|
AutoSync(
|
||||||
|
preferences = get(),
|
||||||
|
networkMode = networkMode(context.applicationContext),
|
||||||
|
hasSource = { get<SourceRepository>().activeSourceIdOnce() != null },
|
||||||
|
requestSync = { get<SyncManager>().requestSync(silent = true) },
|
||||||
|
scope = get(),
|
||||||
|
)
|
||||||
|
}
|
||||||
single { QueueRepository(get()) }
|
single { QueueRepository(get()) }
|
||||||
single { ArtworkStore(downloadsRoot(context)) }
|
single { ArtworkStore(downloadsRoot(context)) }
|
||||||
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
||||||
@@ -111,6 +126,7 @@ fun appModule(
|
|||||||
).also { it.start() }
|
).also { it.start() }
|
||||||
}
|
}
|
||||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
||||||
|
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
||||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||||
single(createdAtStart = true) {
|
single(createdAtStart = true) {
|
||||||
|
|||||||
@@ -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
|
package com.subtracks.playback
|
||||||
|
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.util.Log
|
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
import androidx.media3.common.Format
|
import androidx.media3.common.Format
|
||||||
import androidx.media3.common.MediaItem
|
import androidx.media3.common.MediaItem
|
||||||
@@ -16,6 +15,7 @@ import com.subtracks.UiMessage
|
|||||||
import com.subtracks.data.model.AudioEncoding
|
import com.subtracks.data.model.AudioEncoding
|
||||||
import com.subtracks.data.repo.DownloadRepository
|
import com.subtracks.data.repo.DownloadRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.log.Log
|
||||||
|
|
||||||
class Media3PlayerHandle(
|
class Media3PlayerHandle(
|
||||||
private val controller: MediaController,
|
private val controller: MediaController,
|
||||||
@@ -112,14 +112,19 @@ class Media3PlayerHandle(
|
|||||||
) = controller.replaceMediaItem(index, toMediaItem(item))
|
) = controller.replaceMediaItem(index, toMediaItem(item))
|
||||||
|
|
||||||
override fun seekToIndex(index: Int) {
|
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)
|
controller.seekTo(index, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun seekTo(positionMs: Long) {
|
override fun seekTo(positionMs: Long) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"seekTo(${positionMs}ms) from=${controller.currentPosition} playerDuration=${controller.duration} " +
|
"seekTo(${positionMs}ms) from=${controller.currentPosition} duration=${duration(controller.duration)} " +
|
||||||
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
"itemDuration=${currentItem?.durationMs} state=${controller.playbackState}",
|
||||||
)
|
)
|
||||||
controller.seekTo(positionMs)
|
controller.seekTo(positionMs)
|
||||||
@@ -136,7 +141,7 @@ class Media3PlayerHandle(
|
|||||||
mediaItem: MediaItem?,
|
mediaItem: MediaItem?,
|
||||||
reason: Int,
|
reason: Int,
|
||||||
) {
|
) {
|
||||||
Log.d(TAG, "item=${mediaItem?.mediaId}")
|
Log.i("playback", "${describe(mediaItem)} transition=${transitionReason(reason)}")
|
||||||
listener.onTransition()
|
listener.onTransition()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -147,8 +152,9 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlayerError(error: PlaybackException) {
|
override fun onPlayerError(error: PlaybackException) {
|
||||||
Log.w(
|
Log.w(
|
||||||
TAG,
|
"playback",
|
||||||
"Playback error ${error.errorCodeName} at ${controller.currentPosition}/${controller.duration}",
|
"error ${error.errorCodeName} ${describe(controller.currentMediaItem)} " +
|
||||||
|
"position=${controller.currentPosition} duration=${duration(controller.duration)}",
|
||||||
error,
|
error,
|
||||||
)
|
)
|
||||||
listener.onError(playbackErrorMessage(error))
|
listener.onError(playbackErrorMessage(error))
|
||||||
@@ -156,15 +162,54 @@ class Media3PlayerHandle(
|
|||||||
|
|
||||||
override fun onPlaybackStateChanged(playbackState: Int) {
|
override fun onPlaybackStateChanged(playbackState: Int) {
|
||||||
Log.d(
|
Log.d(
|
||||||
TAG,
|
"playback",
|
||||||
"state=$playbackState position=${controller.currentPosition} " +
|
"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 {
|
private fun toMediaItem(item: QueueItem): MediaItem {
|
||||||
val quality = sourceRepository.quality.value
|
val quality = sourceRepository.quality.value
|
||||||
return queueMediaItem(
|
return queueMediaItem(
|
||||||
@@ -190,10 +235,6 @@ class Media3PlayerHandle(
|
|||||||
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
?.takeIf { it.containsKey(EXTRA_DURATION_MS) }
|
||||||
?.getLong(EXTRA_DURATION_MS),
|
?.getLong(EXTRA_DURATION_MS),
|
||||||
)
|
)
|
||||||
|
|
||||||
private companion object {
|
|
||||||
const val TAG = "SubtracksPlayback"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private const val EXTRA_COVER_ART_ID = "coverArtId"
|
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.QueueWindowItem
|
||||||
import com.subtracks.data.repo.Shuffle
|
import com.subtracks.data.repo.Shuffle
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -127,7 +128,9 @@ class PlaybackController(
|
|||||||
private var positionJob: Job? = null
|
private var positionJob: Job? = null
|
||||||
private var windowJob: Job? = null
|
private var windowJob: Job? = null
|
||||||
private var bufferingJob: Job? = null
|
private var bufferingJob: Job? = null
|
||||||
|
private var stallJob: Job? = null
|
||||||
private var showBuffering = false
|
private var showBuffering = false
|
||||||
|
private var stalled = false
|
||||||
private val startLock = Mutex()
|
private val startLock = Mutex()
|
||||||
|
|
||||||
private var snapshot: QueueSnapshot? = null
|
private var snapshot: QueueSnapshot? = null
|
||||||
@@ -258,6 +261,15 @@ class PlaybackController(
|
|||||||
artistId: String,
|
artistId: String,
|
||||||
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
) = shuffleContext(queueRepository.artistEntry(sourceId, artistId))
|
||||||
|
|
||||||
|
fun shuffleGenre(
|
||||||
|
sourceId: Long,
|
||||||
|
genre: String,
|
||||||
|
) = shuffleContext(queueRepository.genreEntry(sourceId, genre))
|
||||||
|
|
||||||
|
fun shuffleStarred(sourceId: Long) = shuffleContext(queueRepository.starredEntry(sourceId))
|
||||||
|
|
||||||
|
fun shuffleDownloaded(sourceId: Long) = shuffleContext(queueRepository.downloadedEntry(sourceId))
|
||||||
|
|
||||||
private fun playContext(
|
private fun playContext(
|
||||||
entry: QueueEntry,
|
entry: QueueEntry,
|
||||||
start: Long,
|
start: Long,
|
||||||
@@ -1047,6 +1059,9 @@ class PlaybackController(
|
|||||||
windowJob?.cancel()
|
windowJob?.cancel()
|
||||||
bufferingJob?.cancel()
|
bufferingJob?.cancel()
|
||||||
bufferingJob = null
|
bufferingJob = null
|
||||||
|
stallJob?.cancel()
|
||||||
|
stallJob = null
|
||||||
|
stalled = false
|
||||||
showBuffering = false
|
showBuffering = false
|
||||||
snapshot = null
|
snapshot = null
|
||||||
queueSourceId = null
|
queueSourceId = null
|
||||||
@@ -1460,21 +1475,42 @@ class PlaybackController(
|
|||||||
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
suspend fun sourceTitle(context: QueueContext?): String? = context?.let { queueRepository.sourceName(it.kind, it.sourceId, it.refId) }
|
||||||
|
|
||||||
private fun updateBuffering() {
|
private fun updateBuffering() {
|
||||||
|
val player = player
|
||||||
if (player?.isBuffering != true) {
|
if (player?.isBuffering != true) {
|
||||||
bufferingJob?.cancel()
|
bufferingJob?.cancel()
|
||||||
bufferingJob = null
|
bufferingJob = null
|
||||||
|
stallJob?.cancel()
|
||||||
|
stallJob = null
|
||||||
|
stalled = false
|
||||||
showBuffering = false
|
showBuffering = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (showBuffering || bufferingJob?.isActive == true) return
|
if (!showBuffering && bufferingJob?.isActive != true) {
|
||||||
bufferingJob =
|
bufferingJob =
|
||||||
scope.launch {
|
scope.launch {
|
||||||
delay(BUFFERING_INDICATOR_DELAY_MS)
|
delay(BUFFERING_INDICATOR_DELAY_MS)
|
||||||
if (player?.isBuffering == true) {
|
if (player.isBuffering) {
|
||||||
showBuffering = true
|
showBuffering = true
|
||||||
refresh()
|
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()) {
|
private fun refresh(position: Long? = currentPosition()) {
|
||||||
@@ -1541,6 +1577,7 @@ class PlaybackController(
|
|||||||
const val POSITION_TICK_MS = 500L
|
const val POSITION_TICK_MS = 500L
|
||||||
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
const val POSITION_SAVE_INTERVAL_MS = 15_000L
|
||||||
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
const val BUFFERING_INDICATOR_DELAY_MS = 1_000L
|
||||||
|
const val STALL_WATCHDOG_MS = 15_000L
|
||||||
const val QUEUE_WINDOW_RADIUS = 25L
|
const val QUEUE_WINDOW_RADIUS = 25L
|
||||||
const val WINDOW_SHIFT_DELAY_MS = 400L
|
const val WINDOW_SHIFT_DELAY_MS = 400L
|
||||||
const val RESTART_THRESHOLD_MS = 3_000L
|
const val RESTART_THRESHOLD_MS = 3_000L
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import android.content.Context
|
|||||||
import android.content.Intent
|
import android.content.Intent
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.os.SystemClock
|
import android.os.SystemClock
|
||||||
import android.util.Log
|
|
||||||
import androidx.annotation.OptIn
|
import androidx.annotation.OptIn
|
||||||
import androidx.media3.common.AudioAttributes
|
import androidx.media3.common.AudioAttributes
|
||||||
import androidx.media3.common.C
|
import androidx.media3.common.C
|
||||||
@@ -13,8 +12,13 @@ import androidx.media3.common.MediaItem
|
|||||||
import androidx.media3.common.Player
|
import androidx.media3.common.Player
|
||||||
import androidx.media3.common.util.ExperimentalApi
|
import androidx.media3.common.util.ExperimentalApi
|
||||||
import androidx.media3.common.util.UnstableApi
|
import androidx.media3.common.util.UnstableApi
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
import androidx.media3.datasource.DataSource
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DefaultDataSource
|
import androidx.media3.datasource.DefaultDataSource
|
||||||
|
import androidx.media3.datasource.cache.Cache
|
||||||
|
import androidx.media3.datasource.cache.CacheDataSource
|
||||||
|
import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.media3.exoplayer.ExoPlayer
|
import androidx.media3.exoplayer.ExoPlayer
|
||||||
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
|
||||||
@@ -29,9 +33,11 @@ import com.google.common.util.concurrent.Futures
|
|||||||
import com.google.common.util.concurrent.ListenableFuture
|
import com.google.common.util.concurrent.ListenableFuture
|
||||||
import com.subtracks.MainActivity
|
import com.subtracks.MainActivity
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
|
import com.subtracks.UserAgent
|
||||||
import com.subtracks.data.repo.LibraryRepository
|
import com.subtracks.data.repo.LibraryRepository
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.StarType
|
import com.subtracks.data.source.StarType
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
@@ -40,6 +46,7 @@ import kotlinx.coroutines.cancel
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import okhttp3.OkHttpClient
|
import okhttp3.OkHttpClient
|
||||||
import org.koin.core.context.GlobalContext
|
import org.koin.core.context.GlobalContext
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
class PlaybackService : MediaSessionService() {
|
class PlaybackService : MediaSessionService() {
|
||||||
@@ -90,6 +97,7 @@ class PlaybackService : MediaSessionService() {
|
|||||||
val sources = GlobalContext.get().get<SourceRepository>()
|
val sources = GlobalContext.get().get<SourceRepository>()
|
||||||
sourceRepository = sources
|
sourceRepository = sources
|
||||||
library = GlobalContext.get().get<LibraryRepository>()
|
library = GlobalContext.get().get<LibraryRepository>()
|
||||||
|
val cache = StreamCache.open(this)
|
||||||
val exoPlayer =
|
val exoPlayer =
|
||||||
ExoPlayer
|
ExoPlayer
|
||||||
.Builder(this)
|
.Builder(this)
|
||||||
@@ -104,7 +112,13 @@ class PlaybackService : MediaSessionService() {
|
|||||||
.setMediaSourceFactory(
|
.setMediaSourceFactory(
|
||||||
DefaultMediaSourceFactory(this)
|
DefaultMediaSourceFactory(this)
|
||||||
.setEnableClippingInMediaPeriod(true)
|
.setEnableClippingInMediaPeriod(true)
|
||||||
.setDataSourceFactory(mediaDataSourceFactory(this, OkHttpDataSource.Factory(streamingClient()))),
|
.setDataSourceFactory(
|
||||||
|
mediaDataSourceFactory(
|
||||||
|
this,
|
||||||
|
cache,
|
||||||
|
OkHttpDataSource.Factory(streamingClient()).setUserAgent(UserAgent.value),
|
||||||
|
),
|
||||||
|
),
|
||||||
).build()
|
).build()
|
||||||
exoPlayer.addListener(
|
exoPlayer.addListener(
|
||||||
object : Player.Listener {
|
object : Player.Listener {
|
||||||
@@ -183,12 +197,13 @@ class PlaybackService : MediaSessionService() {
|
|||||||
private fun streamingClient(): OkHttpClient =
|
private fun streamingClient(): OkHttpClient =
|
||||||
OkHttpClient
|
OkHttpClient
|
||||||
.Builder()
|
.Builder()
|
||||||
|
.addInterceptor(Log.interceptor())
|
||||||
.addInterceptor { chain ->
|
.addInterceptor { chain ->
|
||||||
val started = SystemClock.elapsedRealtime()
|
val started = SystemClock.elapsedRealtime()
|
||||||
val response = chain.proceed(chain.request())
|
val response = chain.proceed(chain.request())
|
||||||
Log.d(
|
Log.d(
|
||||||
STREAM_TAG,
|
"stream",
|
||||||
"stream range=${chain.request().header("Range")} code=${response.code} " +
|
"range=${chain.request().header("Range")} code=${response.code} " +
|
||||||
"contentRange=${response.header("Content-Range")} " +
|
"contentRange=${response.header("Content-Range")} " +
|
||||||
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
"length=${response.body.contentLength()} in ${SystemClock.elapsedRealtime() - started}ms",
|
||||||
)
|
)
|
||||||
@@ -208,13 +223,50 @@ class PlaybackService : MediaSessionService() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
private companion object {
|
||||||
const val STREAM_TAG = "SubtracksPlayback"
|
|
||||||
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
|
val STAR_COMMAND = SessionCommand("com.subtracks.STAR", Bundle.EMPTY)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process-scoped so it is never released while a loader thread is still committing to it, and so
|
||||||
|
// a recreated service does not try to lock the directory a second time.
|
||||||
|
@OptIn(UnstableApi::class)
|
||||||
|
private object StreamCache {
|
||||||
|
@Volatile
|
||||||
|
private var instance: SimpleCache? = null
|
||||||
|
|
||||||
|
fun open(context: Context): SimpleCache? =
|
||||||
|
instance ?: synchronized(this) {
|
||||||
|
instance ?: runCatching {
|
||||||
|
SimpleCache(
|
||||||
|
File(context.cacheDir, STREAM_CACHE_DIR),
|
||||||
|
LeastRecentlyUsedCacheEvictor(STREAM_CACHE_BYTES),
|
||||||
|
StandaloneDatabaseProvider(context),
|
||||||
|
)
|
||||||
|
}.onFailure { Log.w("playback", "stream cache unavailable: ${it.message}") }
|
||||||
|
.getOrNull()
|
||||||
|
?.also { instance = it }
|
||||||
|
}
|
||||||
|
|
||||||
|
private const val STREAM_CACHE_DIR = "stream-cache"
|
||||||
|
private const val STREAM_CACHE_BYTES = 64L * 1024 * 1024
|
||||||
|
}
|
||||||
|
|
||||||
@OptIn(UnstableApi::class)
|
@OptIn(UnstableApi::class)
|
||||||
internal fun mediaDataSourceFactory(
|
internal fun mediaDataSourceFactory(
|
||||||
context: Context,
|
context: Context,
|
||||||
|
cache: Cache?,
|
||||||
upstream: DataSource.Factory,
|
upstream: DataSource.Factory,
|
||||||
): DataSource.Factory = DefaultDataSource.Factory(context, KnownLengthDataSourceFactory(upstream))
|
): DataSource.Factory {
|
||||||
|
val known = KnownLengthDataSourceFactory(upstream)
|
||||||
|
val source: DataSource.Factory =
|
||||||
|
if (cache == null) {
|
||||||
|
known
|
||||||
|
} else {
|
||||||
|
CacheDataSource
|
||||||
|
.Factory()
|
||||||
|
.setCache(cache)
|
||||||
|
.setUpstreamDataSourceFactory(known)
|
||||||
|
.setFlags(CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR)
|
||||||
|
}
|
||||||
|
return DefaultDataSource.Factory(context, source)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.playback
|
package com.subtracks.playback
|
||||||
|
|
||||||
import com.subtracks.data.source.ServerActionSink
|
import com.subtracks.data.source.ServerActionSink
|
||||||
|
import com.subtracks.log.Log
|
||||||
import kotlinx.coroutines.CancellationException
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -58,6 +59,7 @@ class ScrobblePolicy(
|
|||||||
item?.id != songId ||
|
item?.id != songId ||
|
||||||
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
(item != null && item.id == lastItemId && positionMs < previousPosition && positionMs <= RESTART_POSITION_MS)
|
||||||
if (restarted) {
|
if (restarted) {
|
||||||
|
val alreadyAnnounced = nowPlayingSent && item?.id == songId
|
||||||
songId = item?.id
|
songId = item?.id
|
||||||
lastItemId = null
|
lastItemId = null
|
||||||
playedMs = 0
|
playedMs = 0
|
||||||
@@ -67,7 +69,7 @@ class ScrobblePolicy(
|
|||||||
if (item == null || !isPlaying) return null
|
if (item == null || !isPlaying) return null
|
||||||
nowPlayingSent = true
|
nowPlayingSent = true
|
||||||
startedAt = now()
|
startedAt = now()
|
||||||
return Scrobble.NowPlaying(item.id)
|
return if (alreadyAnnounced) null else Scrobble.NowPlaying(item.id)
|
||||||
}
|
}
|
||||||
lastPositionMs = positionMs
|
lastPositionMs = positionMs
|
||||||
lastItemId = item?.id
|
lastItemId = item?.id
|
||||||
@@ -128,22 +130,28 @@ class Scrobbler(
|
|||||||
private suspend fun submit(event: Scrobble) {
|
private suspend fun submit(event: Scrobble) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is Scrobble.NowPlaying -> {
|
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 -> {
|
is Scrobble.Submission -> {
|
||||||
attempt { recordPlay(event.songId, event.time / 1000) }
|
Log.i("scrobble", "submitting ${event.songId}")
|
||||||
attempt { sink.scrobble(event.songId, event.time) }
|
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 {
|
try {
|
||||||
block()
|
block()
|
||||||
} catch (cancellation: CancellationException) {
|
} catch (cancellation: CancellationException) {
|
||||||
throw cancellation
|
throw cancellation
|
||||||
} catch (_: Exception) {
|
} catch (failure: Exception) {
|
||||||
|
Log.w("scrobble", "$label failed", failure)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -87,9 +87,11 @@ fun CoverArt(
|
|||||||
colorFilter: ColorFilter? = null,
|
colorFilter: ColorFilter? = null,
|
||||||
) {
|
) {
|
||||||
val context = LocalPlatformContext.current
|
val context = LocalPlatformContext.current
|
||||||
var failed by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val refKey = ref?.cacheKey
|
||||||
var thumbnailLoaded by remember(ref, thumbnailRef) { mutableStateOf(false) }
|
val thumbnailKey = thumbnailRef?.cacheKey
|
||||||
var thumbnailRatio by remember(ref, thumbnailRef) { mutableStateOf(thumbnailRef?.cacheKey?.let(ArtworkRatioCache::get)) }
|
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 =
|
val letterScheme =
|
||||||
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
if (showPlaceholder || (failed && !thumbnailLoaded)) {
|
||||||
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
remember(name) { artworkColorsFromSeed(placeholderSeed(name)).scheme }
|
||||||
@@ -102,7 +104,7 @@ fun CoverArt(
|
|||||||
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
.background(letterScheme?.primaryContainer ?: MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
|
||||||
if (square) {
|
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)) {
|
Box(modifier.then(frameModifier)) {
|
||||||
CoverArtContent(
|
CoverArtContent(
|
||||||
ref = ref,
|
ref = ref,
|
||||||
@@ -147,7 +149,7 @@ fun CoverArt(
|
|||||||
val painter =
|
val painter =
|
||||||
rememberAsyncImagePainter(
|
rememberAsyncImagePainter(
|
||||||
model =
|
model =
|
||||||
remember(ref, thumbnailRef, target) {
|
remember(refKey, thumbnailKey, target) {
|
||||||
ref?.let {
|
ref?.let {
|
||||||
imageRequest(
|
imageRequest(
|
||||||
context,
|
context,
|
||||||
@@ -231,7 +233,7 @@ private fun BoxScope.CoverArtContent(
|
|||||||
}
|
}
|
||||||
if (thumbnailRef != null && thumbnailRef != ref) {
|
if (thumbnailRef != null && thumbnailRef != ref) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = remember(thumbnailRef) { imageRequest(context, thumbnailRef, crossfade = false) },
|
model = remember(thumbnailRef.cacheKey) { imageRequest(context, thumbnailRef, crossfade = false) },
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = contentScale,
|
contentScale = contentScale,
|
||||||
colorFilter = colorFilter,
|
colorFilter = colorFilter,
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ import androidx.compose.runtime.derivedStateOf
|
|||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
import androidx.compose.runtime.mutableFloatStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@@ -77,6 +78,7 @@ import com.subtracks.ui.theme.ArtworkTheme
|
|||||||
import com.subtracks.ui.theme.HeroGradient
|
import com.subtracks.ui.theme.HeroGradient
|
||||||
import com.subtracks.ui.theme.baseArtworkColors
|
import com.subtracks.ui.theme.baseArtworkColors
|
||||||
import com.subtracks.ui.theme.heroBarColor
|
import com.subtracks.ui.theme.heroBarColor
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
private const val FADE_DISTANCE_DP = 64
|
private const val FADE_DISTANCE_DP = 64
|
||||||
|
|
||||||
@@ -102,6 +104,7 @@ fun HeroDetailScaffold(
|
|||||||
ArtworkTheme(effectiveArtwork) {
|
ArtworkTheme(effectiveArtwork) {
|
||||||
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
BoxWithConstraints(modifier.fillMaxSize().background(MaterialTheme.colorScheme.background)) {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
val screenHeightPx = with(density) { maxHeight.toPx() }
|
val screenHeightPx = with(density) { maxHeight.toPx() }
|
||||||
@@ -195,7 +198,8 @@ fun HeroDetailScaffold(
|
|||||||
.clickable(
|
.clickable(
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
interactionSource = remember { MutableInteractionSource() },
|
||||||
indication = null,
|
indication = null,
|
||||||
) {},
|
onClickLabel = stringResource(R.string.scroll_to_top),
|
||||||
|
) { scope.launch { listState.animateScrollToItem(0) } },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package com.subtracks.ui.components
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.animation.scaleIn
|
||||||
|
import androidx.compose.animation.scaleOut
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.rounded.Shuffle
|
||||||
|
import androidx.compose.material3.FloatingActionButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.res.stringResource
|
||||||
|
import com.subtracks.R
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ShuffleFab(
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
visible: Boolean = true,
|
||||||
|
) {
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = visible,
|
||||||
|
enter = fadeIn() + scaleIn(),
|
||||||
|
exit = fadeOut() + scaleOut(),
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
|
FloatingActionButton(onClick = onClick) {
|
||||||
|
Icon(Icons.Rounded.Shuffle, contentDescription = stringResource(R.string.controls_shuffle_play))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -88,3 +88,34 @@ fun rememberViewportFill(state: LazyGridState): Dp {
|
|||||||
}
|
}
|
||||||
return with(density) { fillPx.toDp() }
|
return with(density) { fillPx.toDp() }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Scrolls to the top when [request] changes to a new non-null value, but not when it merely becomes
|
||||||
|
* non-null. Selecting a page passes it the current request; only a fresh request (a tab re-tap)
|
||||||
|
* should move the list, so a stale scroll position survives tab switches.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
fun ScrollToTopOnRequest(
|
||||||
|
request: Any?,
|
||||||
|
state: LazyListState,
|
||||||
|
) {
|
||||||
|
var previous by remember { mutableStateOf(request) }
|
||||||
|
LaunchedEffect(request) {
|
||||||
|
val stale = previous
|
||||||
|
previous = request
|
||||||
|
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ScrollToTopOnRequest(
|
||||||
|
request: Any?,
|
||||||
|
state: LazyGridState,
|
||||||
|
) {
|
||||||
|
var previous by remember { mutableStateOf(request) }
|
||||||
|
LaunchedEffect(request) {
|
||||||
|
val stale = previous
|
||||||
|
previous = request
|
||||||
|
if (request != null && stale != null && request != stale) state.animateScrollToItem(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -14,7 +14,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
@@ -31,6 +33,7 @@ import androidx.compose.material3.TopAppBar
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@@ -59,8 +62,10 @@ import com.subtracks.ui.components.CoverArt
|
|||||||
import com.subtracks.ui.components.ItemActions
|
import com.subtracks.ui.components.ItemActions
|
||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
|
import com.subtracks.ui.components.ShuffleFab
|
||||||
import com.subtracks.ui.library.PlaylistsContent
|
import com.subtracks.ui.library.PlaylistsContent
|
||||||
import com.subtracks.ui.library.SongRow
|
import com.subtracks.ui.library.SongRow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.parameter.parametersOf
|
import org.koin.core.parameter.parametersOf
|
||||||
@@ -110,6 +115,7 @@ fun HomeListRoute(
|
|||||||
onPlaylistClick = onPlaylistClick,
|
onPlaylistClick = onPlaylistClick,
|
||||||
onSongClick = viewModel::play,
|
onSongClick = viewModel::play,
|
||||||
onLongClick = { contextMenuHost?.show(it, actions) },
|
onLongClick = { contextMenuHost?.show(it, actions) },
|
||||||
|
onShuffle = if (viewModel.canShuffle) viewModel::shuffle else null,
|
||||||
onGenreClick = onGenreClick,
|
onGenreClick = onGenreClick,
|
||||||
onDecadeClick = onDecadeClick,
|
onDecadeClick = onDecadeClick,
|
||||||
)
|
)
|
||||||
@@ -134,11 +140,14 @@ fun HomeListScreen(
|
|||||||
onPlaylistClick: (Playlist) -> Unit = {},
|
onPlaylistClick: (Playlist) -> Unit = {},
|
||||||
onSongClick: (Song) -> Unit = {},
|
onSongClick: (Song) -> Unit = {},
|
||||||
onLongClick: (MenuTarget) -> Unit = {},
|
onLongClick: (MenuTarget) -> Unit = {},
|
||||||
|
onShuffle: (() -> Unit)? = null,
|
||||||
onGenreClick: (String) -> Unit = {},
|
onGenreClick: (String) -> Unit = {},
|
||||||
onDecadeClick: (Long) -> Unit = {},
|
onDecadeClick: (Long) -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val now = remember { System.currentTimeMillis() }
|
val now = remember { System.currentTimeMillis() }
|
||||||
val resources = LocalResources.current
|
val resources = LocalResources.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val songListState = rememberLazyListState()
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
topBar = {
|
||||||
@@ -149,8 +158,21 @@ fun HomeListScreen(
|
|||||||
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
|
Icon(Icons.AutoMirrored.Rounded.ArrowBack, contentDescription = stringResource(R.string.navigation_back))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
modifier =
|
||||||
|
if (onShuffle != null) {
|
||||||
|
Modifier.clickable(onClickLabel = stringResource(R.string.scroll_to_top)) {
|
||||||
|
scope.launch { songListState.animateScrollToItem(0) }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
floatingActionButton = {
|
||||||
|
if (onShuffle != null && songs.itemCount > 0) {
|
||||||
|
ShuffleFab(onClick = onShuffle)
|
||||||
|
}
|
||||||
|
},
|
||||||
) { padding ->
|
) { padding ->
|
||||||
val downloaded = request.downloaded
|
val downloaded = request.downloaded
|
||||||
if (downloaded != null) {
|
if (downloaded != null) {
|
||||||
@@ -184,6 +206,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -237,6 +260,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -254,6 +278,7 @@ fun HomeListScreen(
|
|||||||
playingSongId = playingSongId,
|
playingSongId = playingSongId,
|
||||||
onSongClick = onSongClick,
|
onSongClick = onSongClick,
|
||||||
onLongClick = onLongClick,
|
onLongClick = onLongClick,
|
||||||
|
listState = songListState,
|
||||||
modifier = Modifier.padding(padding),
|
modifier = Modifier.padding(padding),
|
||||||
)
|
)
|
||||||
} else {
|
} else {
|
||||||
@@ -297,6 +322,7 @@ private fun SongsList(
|
|||||||
playingSongId: String?,
|
playingSongId: String?,
|
||||||
onSongClick: (Song) -> Unit,
|
onSongClick: (Song) -> Unit,
|
||||||
onLongClick: (MenuTarget) -> Unit,
|
onLongClick: (MenuTarget) -> Unit,
|
||||||
|
listState: LazyListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
if (songs.itemCount == 0 && songs.loadState.refresh is LoadState.Loading) {
|
||||||
@@ -304,8 +330,9 @@ private fun SongsList(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
|
state = listState,
|
||||||
modifier = modifier.fillMaxSize(),
|
modifier = modifier.fillMaxSize(),
|
||||||
contentPadding = PaddingValues(bottom = 24.dp),
|
contentPadding = PaddingValues(bottom = 88.dp),
|
||||||
) {
|
) {
|
||||||
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
items(count = songs.itemCount, key = songs.itemKey { it.song.id }) { index ->
|
||||||
val item = songs[index]
|
val item = songs[index]
|
||||||
|
|||||||
@@ -21,9 +21,11 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.SharingStarted
|
import kotlinx.coroutines.flow.SharingStarted
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.flowOf
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
data class HomeListRequest(
|
data class HomeListRequest(
|
||||||
val title: String,
|
val title: String,
|
||||||
@@ -51,6 +53,11 @@ class HomeListViewModel(
|
|||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val sourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
|
|
||||||
|
val canShuffle: Boolean =
|
||||||
|
request.genre != null ||
|
||||||
|
request.section == HomeSection.RecentlyStarredSongs ||
|
||||||
|
request.downloaded == OfflineListKind.Songs
|
||||||
|
|
||||||
val albums: Flow<PagingData<Album>> =
|
val albums: Flow<PagingData<Album>> =
|
||||||
sourceId
|
sourceId
|
||||||
.flatMapLatest { id ->
|
.flatMapLatest { id ->
|
||||||
@@ -173,4 +180,18 @@ class HomeListViewModel(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun shuffle() {
|
||||||
|
val genre = request.genre
|
||||||
|
val downloaded = request.downloaded
|
||||||
|
val starred = request.section == HomeSection.RecentlyStarredSongs
|
||||||
|
viewModelScope.launch {
|
||||||
|
val id = sourceId.first()
|
||||||
|
when {
|
||||||
|
genre != null -> playbackController.shuffleGenre(id, genre)
|
||||||
|
downloaded == OfflineListKind.Songs -> playbackController.shuffleDownloaded(id)
|
||||||
|
starred -> playbackController.shuffleStarred(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.StateFlow
|
|||||||
import kotlinx.coroutines.flow.combine
|
import kotlinx.coroutines.flow.combine
|
||||||
import kotlinx.coroutines.flow.filterNotNull
|
import kotlinx.coroutines.flow.filterNotNull
|
||||||
import kotlinx.coroutines.flow.flatMapLatest
|
import kotlinx.coroutines.flow.flatMapLatest
|
||||||
import kotlinx.coroutines.flow.map
|
|
||||||
import kotlinx.coroutines.flow.stateIn
|
import kotlinx.coroutines.flow.stateIn
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
@@ -53,11 +52,6 @@ class HomeViewModel(
|
|||||||
private val syncManager: SyncManager,
|
private val syncManager: SyncManager,
|
||||||
private val playbackController: PlaybackController,
|
private val playbackController: PlaybackController,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
val syncing: StateFlow<Boolean> =
|
|
||||||
syncManager.status
|
|
||||||
.map { it == SyncStatus.Running }
|
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
|
||||||
|
|
||||||
val offline: StateFlow<Boolean> =
|
val offline: StateFlow<Boolean> =
|
||||||
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
@@ -141,7 +135,9 @@ class HomeViewModel(
|
|||||||
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun sync() {
|
||||||
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
|
}
|
||||||
|
|
||||||
private data class PlayRows(
|
private data class PlayRows(
|
||||||
val recentAlbums: List<Album>,
|
val recentAlbums: List<Album>,
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
|
|
||||||
@@ -56,6 +57,7 @@ fun AlbumsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
|
scrollToTopKey: Any? = null,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -78,6 +80,7 @@ fun AlbumsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val gridState = rememberLazyGridState()
|
val gridState = rememberLazyGridState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { gridState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, gridState)
|
||||||
val fill = rememberViewportFill(gridState)
|
val fill = rememberViewportFill(gridState)
|
||||||
LazyVerticalGrid(
|
LazyVerticalGrid(
|
||||||
state = gridState,
|
state = gridState,
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
import com.subtracks.ui.theme.PrefetchArtworkSeeds
|
||||||
|
|
||||||
@@ -53,6 +54,7 @@ fun ArtistsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
|
scrollToTopKey: Any? = null,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -70,6 +72,7 @@ fun ArtistsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.rounded.Person
|
|||||||
import androidx.compose.material.icons.rounded.Remove
|
import androidx.compose.material.icons.rounded.Remove
|
||||||
import androidx.compose.material.icons.rounded.Search
|
import androidx.compose.material.icons.rounded.Search
|
||||||
import androidx.compose.material.icons.rounded.Settings
|
import androidx.compose.material.icons.rounded.Settings
|
||||||
|
import androidx.compose.material3.CircularProgressIndicator
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.FilterChip
|
import androidx.compose.material3.FilterChip
|
||||||
import androidx.compose.material3.FilterChipDefaults
|
import androidx.compose.material3.FilterChipDefaults
|
||||||
@@ -243,6 +244,7 @@ fun LibraryRoute(
|
|||||||
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
||||||
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
||||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||||
|
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
|
||||||
val listTab = selectedTab.listTab()
|
val listTab = selectedTab.listTab()
|
||||||
LaunchedEffect(selectedTab) {
|
LaunchedEffect(selectedTab) {
|
||||||
if (previousTab != selectedTab) {
|
if (previousTab != selectedTab) {
|
||||||
@@ -296,6 +298,7 @@ fun LibraryRoute(
|
|||||||
selectedTab = selectedTab,
|
selectedTab = selectedTab,
|
||||||
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
||||||
syncing = syncing,
|
syncing = syncing,
|
||||||
|
refreshing = refreshing,
|
||||||
bottomInset = bottomInset,
|
bottomInset = bottomInset,
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
albums = viewModel.albums,
|
albums = viewModel.albums,
|
||||||
@@ -392,6 +395,7 @@ fun LibraryScreen(
|
|||||||
onExitOffline: () -> Unit = {},
|
onExitOffline: () -> Unit = {},
|
||||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||||
syncing: Boolean = false,
|
syncing: Boolean = false,
|
||||||
|
refreshing: Boolean = false,
|
||||||
bottomInset: Dp = 0.dp,
|
bottomInset: Dp = 0.dp,
|
||||||
listQuery: ListQuery = ListQuery(""),
|
listQuery: ListQuery = ListQuery(""),
|
||||||
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
||||||
@@ -418,6 +422,7 @@ fun LibraryScreen(
|
|||||||
)
|
)
|
||||||
var showOptions by rememberSaveable { mutableStateOf(false) }
|
var showOptions by rememberSaveable { mutableStateOf(false) }
|
||||||
var searchActive by rememberSaveable { mutableStateOf(false) }
|
var searchActive by rememberSaveable { mutableStateOf(false) }
|
||||||
|
var topRequest by remember { mutableIntStateOf(0) }
|
||||||
val searchFocus = remember { FocusRequester() }
|
val searchFocus = remember { FocusRequester() }
|
||||||
|
|
||||||
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
// Search mode is left open so the typed query survives; MainNavigation dismisses the keyboard.
|
||||||
@@ -470,15 +475,16 @@ fun LibraryScreen(
|
|||||||
) { page ->
|
) { page ->
|
||||||
val pageTab = LibraryTab.entries[page]
|
val pageTab = LibraryTab.entries[page]
|
||||||
val resetKey = resetKeys[pageTab]
|
val resetKey = resetKeys[pageTab]
|
||||||
|
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
|
||||||
val pullToRefreshState = rememberPullToRefreshState()
|
val pullToRefreshState = rememberPullToRefreshState()
|
||||||
PullToRefreshBox(
|
PullToRefreshBox(
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
onRefresh = onSync,
|
onRefresh = onSync,
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
indicator = {
|
indicator = {
|
||||||
PullToRefreshDefaults.Indicator(
|
PullToRefreshDefaults.Indicator(
|
||||||
state = pullToRefreshState,
|
state = pullToRefreshState,
|
||||||
isRefreshing = syncing,
|
isRefreshing = refreshing,
|
||||||
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
||||||
containerColor = headerColor,
|
containerColor = headerColor,
|
||||||
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
||||||
@@ -507,6 +513,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = albumDownloads,
|
downloadStatuses = albumDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -526,6 +533,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = artistDownloads,
|
downloadStatuses = artistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,6 +553,7 @@ fun LibraryScreen(
|
|||||||
topInset = listTopInset,
|
topInset = listTopInset,
|
||||||
onSync = onSync,
|
onSync = onSync,
|
||||||
downloadStatuses = playlistDownloads,
|
downloadStatuses = playlistDownloads,
|
||||||
|
scrollToTopKey = scrollToTopKey,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -570,11 +579,14 @@ fun LibraryScreen(
|
|||||||
) {
|
) {
|
||||||
LibraryTabs(
|
LibraryTabs(
|
||||||
pagerState = pagerState,
|
pagerState = pagerState,
|
||||||
onTabSelected = onTabSelected,
|
onTabSelected = { tab ->
|
||||||
|
if (tab == selectedTab) topRequest++ else onTabSelected(tab)
|
||||||
|
},
|
||||||
onOpenSettings = onOpenSettings,
|
onOpenSettings = onOpenSettings,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
onExitOffline = onExitOffline,
|
onExitOffline = onExitOffline,
|
||||||
artwork = artwork,
|
artwork = artwork,
|
||||||
|
syncing = syncing,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -841,6 +853,7 @@ internal fun LibraryTabs(
|
|||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
onExitOffline: () -> Unit = {},
|
onExitOffline: () -> Unit = {},
|
||||||
artwork: ArtworkColors?,
|
artwork: ArtworkColors?,
|
||||||
|
syncing: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val indicatorStretch = 18.dp
|
val indicatorStretch = 18.dp
|
||||||
val density = LocalDensity.current
|
val density = LocalDensity.current
|
||||||
@@ -932,11 +945,20 @@ internal fun LibraryTabs(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
IconButton(onClick = onOpenSettings) {
|
IconButton(onClick = onOpenSettings) {
|
||||||
Icon(
|
Box(contentAlignment = Alignment.Center) {
|
||||||
imageVector = Icons.Rounded.Settings,
|
Icon(
|
||||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
imageVector = Icons.Rounded.Settings,
|
||||||
tint = MaterialTheme.colorScheme.onBackground,
|
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||||
)
|
tint = MaterialTheme.colorScheme.onBackground,
|
||||||
|
)
|
||||||
|
if (syncing) {
|
||||||
|
CircularProgressIndicator(
|
||||||
|
modifier = Modifier.size(32.dp),
|
||||||
|
strokeWidth = 2.dp,
|
||||||
|
color = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,12 @@ class LibraryViewModel(
|
|||||||
|
|
||||||
val syncing: StateFlow<Boolean> =
|
val syncing: StateFlow<Boolean> =
|
||||||
syncManager.status
|
syncManager.status
|
||||||
.map { it == SyncStatus.Running }
|
.map { it is SyncStatus.Running }
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
|
val refreshing: StateFlow<Boolean> =
|
||||||
|
syncManager.status
|
||||||
|
.map { it is SyncStatus.Running && !it.silent }
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||||
@@ -206,7 +211,9 @@ class LibraryViewModel(
|
|||||||
thumbnail: Boolean,
|
thumbnail: Boolean,
|
||||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||||
|
|
||||||
fun sync() = syncManager.requestSync()
|
fun sync() {
|
||||||
|
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ import com.subtracks.ui.components.ListDownloadIndicator
|
|||||||
import com.subtracks.ui.components.LoadingState
|
import com.subtracks.ui.components.LoadingState
|
||||||
import com.subtracks.ui.components.MenuTarget
|
import com.subtracks.ui.components.MenuTarget
|
||||||
import com.subtracks.ui.components.ResetScrollOnChange
|
import com.subtracks.ui.components.ResetScrollOnChange
|
||||||
|
import com.subtracks.ui.components.ScrollToTopOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@@ -52,6 +53,7 @@ fun PlaylistsContent(
|
|||||||
topInset: Dp = 0.dp,
|
topInset: Dp = 0.dp,
|
||||||
onSync: () -> Unit = {},
|
onSync: () -> Unit = {},
|
||||||
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
downloadStatuses: Map<String, ListDownloadStatus> = emptyMap(),
|
||||||
|
scrollToTopKey: Any? = null,
|
||||||
) {
|
) {
|
||||||
when {
|
when {
|
||||||
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
items.itemCount == 0 && items.loadState.refresh is LoadState.Loading -> {
|
||||||
@@ -74,6 +76,7 @@ fun PlaylistsContent(
|
|||||||
else -> {
|
else -> {
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
ResetScrollOnChange(resetKey, { items.loadState.refresh }) { listState.scrollToItem(0) }
|
||||||
|
ScrollToTopOnRequest(scrollToTopKey, listState)
|
||||||
val fill = rememberViewportFill(listState)
|
val fill = rememberViewportFill(listState)
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
state = listState,
|
state = listState,
|
||||||
|
|||||||
@@ -113,6 +113,8 @@ class QueueViewModel(
|
|||||||
var generation by mutableIntStateOf(0)
|
var generation by mutableIntStateOf(0)
|
||||||
private set
|
private set
|
||||||
|
|
||||||
|
private var reorderPending = false
|
||||||
|
|
||||||
private var nextId = 0L
|
private var nextId = 0L
|
||||||
private val mutex = Mutex()
|
private val mutex = Mutex()
|
||||||
|
|
||||||
@@ -249,12 +251,19 @@ class QueueViewModel(
|
|||||||
fun move(
|
fun move(
|
||||||
from: Long,
|
from: Long,
|
||||||
to: Long,
|
to: Long,
|
||||||
) {
|
): Boolean {
|
||||||
if (from == to) return
|
if (from == to) return false
|
||||||
|
if (reorderPending) return false
|
||||||
|
reorderPending = true
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackController.move(from, to)
|
try {
|
||||||
mutex.withLock { reconcileLocked() }
|
playbackController.move(from, to)
|
||||||
|
mutex.withLock { reconcileLocked() }
|
||||||
|
} finally {
|
||||||
|
reorderPending = false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
fun remove(position: Long) {
|
fun remove(position: Long) {
|
||||||
@@ -331,7 +340,7 @@ fun QueueScreen(
|
|||||||
onPlay: (Long) -> Unit,
|
onPlay: (Long) -> Unit,
|
||||||
onRemove: (Long) -> Unit,
|
onRemove: (Long) -> Unit,
|
||||||
onReorder: (Int, Int) -> Unit,
|
onReorder: (Int, Int) -> Unit,
|
||||||
onMove: (Long, Long) -> Unit,
|
onMove: (Long, Long) -> Boolean,
|
||||||
onLoadOlder: () -> Unit,
|
onLoadOlder: () -> Unit,
|
||||||
onLoadNewer: () -> Unit,
|
onLoadNewer: () -> Unit,
|
||||||
onUndo: () -> Unit,
|
onUndo: () -> Unit,
|
||||||
@@ -347,7 +356,6 @@ fun QueueScreen(
|
|||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var dragId by remember { mutableStateOf<Long?>(null) }
|
var dragId by remember { mutableStateOf<Long?>(null) }
|
||||||
var dragFrom by remember { mutableStateOf<Long?>(null) }
|
|
||||||
val undoLabel = stringResource(R.string.undo)
|
val undoLabel = stringResource(R.string.undo)
|
||||||
val queueReordered = stringResource(R.string.queue_reordered)
|
val queueReordered = stringResource(R.string.queue_reordered)
|
||||||
val queueRemoved = stringResource(R.string.queue_removed)
|
val queueRemoved = stringResource(R.string.queue_removed)
|
||||||
@@ -470,28 +478,14 @@ fun QueueScreen(
|
|||||||
null
|
null
|
||||||
} else {
|
} else {
|
||||||
Modifier.draggableHandle(
|
Modifier.draggableHandle(
|
||||||
onDragStarted = {
|
onDragStarted = { dragId = row.id },
|
||||||
dragId = row.id
|
|
||||||
dragFrom = row.position
|
|
||||||
},
|
|
||||||
onDragStopped = {
|
onDragStopped = {
|
||||||
val id = dragId
|
dragId?.let { id ->
|
||||||
val from = dragFrom
|
rows.dropMoveFor(id)?.let { move ->
|
||||||
if (id != null && from != null) {
|
if (onMove(move.from, move.to)) showUndo(queueReordered)
|
||||||
val draggedIndex = rows.indexOfFirst { it.id == id }
|
|
||||||
val to =
|
|
||||||
if (draggedIndex < 0) {
|
|
||||||
from
|
|
||||||
} else {
|
|
||||||
dropTarget(rows, draggedIndex, from)
|
|
||||||
}
|
|
||||||
if (to != from) {
|
|
||||||
onMove(from, to)
|
|
||||||
showUndo(queueReordered)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dragId = null
|
dragId = null
|
||||||
dragFrom = null
|
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
@@ -563,6 +557,19 @@ internal fun dropTarget(
|
|||||||
return (before - if (before > from) 1 else 0) + 1
|
return (before - if (before > from) 1 else 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal data class QueueMove(
|
||||||
|
val from: Long,
|
||||||
|
val to: Long,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun List<QueueRow>.dropMoveFor(draggedId: Long): QueueMove? {
|
||||||
|
val index = indexOfFirst { it.id == draggedId }
|
||||||
|
if (index < 0) return null
|
||||||
|
val from = this[index].position
|
||||||
|
val to = dropTarget(this, index, from)
|
||||||
|
return if (to == from) null else QueueMove(from, to)
|
||||||
|
}
|
||||||
|
|
||||||
@Suppress("ModifierParameter")
|
@Suppress("ModifierParameter")
|
||||||
@Composable
|
@Composable
|
||||||
private fun QueueRowItem(
|
private fun QueueRowItem(
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package com.subtracks.ui.settings
|
package com.subtracks.ui.settings
|
||||||
|
|
||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
|
import android.widget.Toast
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
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.Favorite
|
||||||
import androidx.compose.material.icons.rounded.Info
|
import androidx.compose.material.icons.rounded.Info
|
||||||
import androidx.compose.material.icons.rounded.Language
|
import androidx.compose.material.icons.rounded.Language
|
||||||
|
import androidx.compose.material.icons.rounded.Share
|
||||||
import androidx.compose.material3.AlertDialog
|
import androidx.compose.material3.AlertDialog
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
@@ -47,11 +49,13 @@ import androidx.compose.runtime.getValue
|
|||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.Brush
|
import androidx.compose.ui.graphics.Brush
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.platform.LocalContext
|
||||||
import androidx.compose.ui.platform.LocalResources
|
import androidx.compose.ui.platform.LocalResources
|
||||||
import androidx.compose.ui.platform.LocalUriHandler
|
import androidx.compose.ui.platform.LocalUriHandler
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
@@ -61,11 +65,17 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
|||||||
import com.subtracks.BuildConfig
|
import com.subtracks.BuildConfig
|
||||||
import com.subtracks.R
|
import com.subtracks.R
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
|
import com.subtracks.log.Log
|
||||||
import com.subtracks.ui.components.DismissOnRequest
|
import com.subtracks.ui.components.DismissOnRequest
|
||||||
import com.subtracks.ui.components.rememberViewportFill
|
import com.subtracks.ui.components.rememberViewportFill
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.emptyFlow
|
import kotlinx.coroutines.flow.emptyFlow
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
|
|
||||||
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
private val bitrateOptions = listOf(0, 24, 32, 64, 96, 128, 192, 256, 320)
|
||||||
@@ -74,7 +84,7 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
|
|||||||
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
||||||
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
||||||
|
|
||||||
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency }
|
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun SettingsRoute(
|
fun SettingsRoute(
|
||||||
@@ -91,32 +101,60 @@ fun SettingsRoute(
|
|||||||
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
||||||
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
||||||
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
||||||
|
val syncMode by viewModel.syncMode.collectAsStateWithLifecycle()
|
||||||
|
val syncIntervalMinutes by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle()
|
||||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||||
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
val offline by viewModel.offline.collectAsStateWithLifecycle()
|
||||||
|
val verboseLogging by viewModel.verboseLogging.collectAsStateWithLifecycle()
|
||||||
|
val context = LocalContext.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
SettingsScreen(
|
SettingsScreen(
|
||||||
sources = sources,
|
sources = sources,
|
||||||
activeSourceId = activeSourceId,
|
activeSourceId = activeSourceId,
|
||||||
wifiQuality = wifiQuality,
|
wifiQuality = wifiQuality,
|
||||||
mobileQuality = mobileQuality,
|
mobileQuality = mobileQuality,
|
||||||
syncConcurrency = syncConcurrency,
|
syncConcurrency = syncConcurrency,
|
||||||
|
syncMode = syncMode,
|
||||||
|
syncInterval = syncIntervalMinutes,
|
||||||
downloadQuality = downloadQuality,
|
downloadQuality = downloadQuality,
|
||||||
downloadOverMetered = downloadOverMetered,
|
downloadOverMetered = downloadOverMetered,
|
||||||
scrobbling = scrobbling,
|
scrobbling = scrobbling,
|
||||||
offline = offline,
|
offline = offline,
|
||||||
|
verboseLogging = verboseLogging,
|
||||||
onSelectSource = viewModel::selectSource,
|
onSelectSource = viewModel::selectSource,
|
||||||
onEditServer = onEditServer,
|
onEditServer = onEditServer,
|
||||||
onWifiQualityChange = viewModel::setWifiQuality,
|
onWifiQualityChange = viewModel::setWifiQuality,
|
||||||
onMobileQualityChange = viewModel::setMobileQuality,
|
onMobileQualityChange = viewModel::setMobileQuality,
|
||||||
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
||||||
|
onSyncModeChange = viewModel::setSyncMode,
|
||||||
|
onSyncIntervalChange = viewModel::setSyncIntervalMinutes,
|
||||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||||
onScrobblingChange = viewModel::setScrobbling,
|
onScrobblingChange = viewModel::setScrobbling,
|
||||||
onOfflineChange = viewModel::setOfflineMode,
|
onOfflineChange = viewModel::setOfflineMode,
|
||||||
|
onVerboseLoggingChange = viewModel::setVerboseLogging,
|
||||||
onAddServer = onAddServer,
|
onAddServer = onAddServer,
|
||||||
onOpenDownloads = onOpenDownloads,
|
onOpenDownloads = onOpenDownloads,
|
||||||
onOpenLicenses = onOpenLicenses,
|
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,
|
onBack = onBack,
|
||||||
dismissals = dismissals,
|
dismissals = dismissals,
|
||||||
)
|
)
|
||||||
@@ -135,18 +173,25 @@ fun SettingsScreen(
|
|||||||
scrobbling: Boolean,
|
scrobbling: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
offline: Boolean = false,
|
offline: Boolean = false,
|
||||||
|
verboseLogging: Boolean = false,
|
||||||
|
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||||
|
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||||
onSelectSource: (Long) -> Unit,
|
onSelectSource: (Long) -> Unit,
|
||||||
onEditServer: (Long) -> Unit,
|
onEditServer: (Long) -> Unit,
|
||||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||||
onMobileQualityChange: (StreamQuality) -> Unit,
|
onMobileQualityChange: (StreamQuality) -> Unit,
|
||||||
onSyncConcurrencyChange: (Int) -> Unit,
|
onSyncConcurrencyChange: (Int) -> Unit,
|
||||||
|
onSyncModeChange: (SyncMode) -> Unit = {},
|
||||||
|
onSyncIntervalChange: (Int) -> Unit = {},
|
||||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||||
onScrobblingChange: (Boolean) -> Unit,
|
onScrobblingChange: (Boolean) -> Unit,
|
||||||
onOfflineChange: (Boolean) -> Unit = {},
|
onOfflineChange: (Boolean) -> Unit = {},
|
||||||
|
onVerboseLoggingChange: (Boolean) -> Unit = {},
|
||||||
onAddServer: () -> Unit,
|
onAddServer: () -> Unit,
|
||||||
onOpenDownloads: () -> Unit,
|
onOpenDownloads: () -> Unit,
|
||||||
onOpenLicenses: () -> Unit,
|
onOpenLicenses: () -> Unit,
|
||||||
|
onShareLogs: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
dismissals: Flow<Unit> = emptyFlow(),
|
dismissals: Flow<Unit> = emptyFlow(),
|
||||||
) {
|
) {
|
||||||
@@ -255,6 +300,22 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||||
|
supportingContent = { Text(syncModeLabel(LocalResources.current, syncMode)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SyncMode },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
item {
|
||||||
|
ListItem(
|
||||||
|
headlineContent = { Text(stringResource(R.string.sync_interval)) },
|
||||||
|
supportingContent = { Text(syncIntervalLabel(LocalResources.current, syncInterval)) },
|
||||||
|
modifier = Modifier.clickable { dialog = SettingsDialog.SyncInterval },
|
||||||
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
|
)
|
||||||
|
}
|
||||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
@@ -321,12 +382,50 @@ fun SettingsScreen(
|
|||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
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 { SectionHeader(stringResource(R.string.settings_about_name)) }
|
||||||
item {
|
item {
|
||||||
ListItem(
|
ListItem(
|
||||||
headlineContent = { Text(stringResource(R.string.app_name)) },
|
headlineContent = { Text(stringResource(R.string.app_name)) },
|
||||||
supportingContent = {
|
supportingContent = {
|
||||||
Text(stringResource(R.string.settings_about_version, BuildConfig.VERSION_NAME))
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
|
Text(stringResource(R.string.settings_about_version, BuildConfig.VERSION_NAME))
|
||||||
|
if (BuildConfig.COMMIT_HASH.isNotEmpty()) {
|
||||||
|
Spacer(Modifier.width(8.dp))
|
||||||
|
Text(
|
||||||
|
BuildConfig.COMMIT_HASH,
|
||||||
|
color = MaterialTheme.colorScheme.outline,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
},
|
},
|
||||||
leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) },
|
leadingContent = { Icon(Icons.Rounded.Info, contentDescription = null) },
|
||||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||||
@@ -404,6 +503,22 @@ fun SettingsScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SyncInterval -> {
|
||||||
|
SyncIntervalDialog(
|
||||||
|
selected = syncInterval,
|
||||||
|
onSelect = onSyncIntervalChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
SettingsDialog.SyncMode -> {
|
||||||
|
SyncModeDialog(
|
||||||
|
selected = syncMode,
|
||||||
|
onSelect = onSyncModeChange,
|
||||||
|
onDismiss = { dialog = null },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
null -> {
|
null -> {
|
||||||
Unit
|
Unit
|
||||||
}
|
}
|
||||||
@@ -429,11 +544,56 @@ private fun bitrateLabel(
|
|||||||
|
|
||||||
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
|
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
|
||||||
|
|
||||||
|
private val syncIntervalOptions = listOf(15, 60, 360, 720, DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||||
|
|
||||||
private fun concurrencyLabel(
|
private fun concurrencyLabel(
|
||||||
resources: Resources,
|
resources: Resources,
|
||||||
value: Int,
|
value: Int,
|
||||||
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
|
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
|
||||||
|
|
||||||
|
private fun syncIntervalLabel(
|
||||||
|
resources: Resources,
|
||||||
|
minutes: Int,
|
||||||
|
): String =
|
||||||
|
if (minutes >= 60 && minutes % 60 == 0) {
|
||||||
|
resources.getQuantityString(R.plurals.sync_interval_hours, minutes / 60, minutes / 60)
|
||||||
|
} else {
|
||||||
|
resources.getQuantityString(R.plurals.sync_interval_minutes, minutes, minutes)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SyncIntervalDialog(
|
||||||
|
selected: Int,
|
||||||
|
onSelect: (Int) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableIntStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.sync_interval)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.sync_interval_prompt),
|
||||||
|
options = syncIntervalOptions.map { it to syncIntervalLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ConcurrencyDialog(
|
private fun ConcurrencyDialog(
|
||||||
selected: Int,
|
selected: Int,
|
||||||
@@ -467,6 +627,51 @@ private fun ConcurrencyDialog(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private val syncModeOptions = SyncMode.entries
|
||||||
|
|
||||||
|
private fun syncModeLabel(
|
||||||
|
resources: Resources,
|
||||||
|
mode: SyncMode,
|
||||||
|
): String =
|
||||||
|
when (mode) {
|
||||||
|
SyncMode.Off -> resources.getString(R.string.sync_mode_off)
|
||||||
|
SyncMode.WifiOnly -> resources.getString(R.string.sync_mode_wifi)
|
||||||
|
SyncMode.Always -> resources.getString(R.string.sync_mode_always)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun SyncModeDialog(
|
||||||
|
selected: SyncMode,
|
||||||
|
onSelect: (SyncMode) -> Unit,
|
||||||
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resources = LocalResources.current
|
||||||
|
var draft by remember(selected) { mutableStateOf(selected) }
|
||||||
|
AlertDialog(
|
||||||
|
onDismissRequest = onDismiss,
|
||||||
|
title = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||||
|
text = {
|
||||||
|
ChoiceGroup(
|
||||||
|
header = stringResource(R.string.sync_mode_prompt),
|
||||||
|
options = syncModeOptions.map { it to syncModeLabel(resources, it) },
|
||||||
|
selected = draft,
|
||||||
|
onSelect = { draft = it },
|
||||||
|
)
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(
|
||||||
|
onClick = {
|
||||||
|
onSelect(draft)
|
||||||
|
onDismiss()
|
||||||
|
},
|
||||||
|
) { Text(stringResource(R.string.action_done)) }
|
||||||
|
},
|
||||||
|
dismissButton = {
|
||||||
|
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun QualityDialog(
|
private fun QualityDialog(
|
||||||
title: String,
|
title: String,
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModel
|
|||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import com.subtracks.data.model.Source
|
import com.subtracks.data.model.Source
|
||||||
import com.subtracks.data.net.NetworkMode
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
import com.subtracks.data.prefs.StreamQuality
|
import com.subtracks.data.prefs.StreamQuality
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.repo.SourceRepository
|
import com.subtracks.data.repo.SourceRepository
|
||||||
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||||
@@ -48,6 +50,16 @@ class SettingsViewModel(
|
|||||||
.syncConcurrency()
|
.syncConcurrency()
|
||||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
||||||
|
|
||||||
|
val syncMode: StateFlow<SyncMode> =
|
||||||
|
userPreferences
|
||||||
|
.syncMode()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncMode.WifiOnly)
|
||||||
|
|
||||||
|
val syncIntervalMinutes: StateFlow<Int> =
|
||||||
|
userPreferences
|
||||||
|
.syncIntervalMinutes()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||||
|
|
||||||
val scrobbling: StateFlow<Boolean> =
|
val scrobbling: StateFlow<Boolean> =
|
||||||
userPreferences
|
userPreferences
|
||||||
.scrobbling()
|
.scrobbling()
|
||||||
@@ -55,10 +67,19 @@ class SettingsViewModel(
|
|||||||
|
|
||||||
val offline: StateFlow<Boolean> = sourceRepository.offline
|
val offline: StateFlow<Boolean> = sourceRepository.offline
|
||||||
|
|
||||||
|
val verboseLogging: StateFlow<Boolean> =
|
||||||
|
userPreferences
|
||||||
|
.verboseLogging()
|
||||||
|
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||||
|
|
||||||
fun setOfflineMode(enabled: Boolean) {
|
fun setOfflineMode(enabled: Boolean) {
|
||||||
sourceRepository.setOfflineMode(enabled)
|
sourceRepository.setOfflineMode(enabled)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setVerboseLogging(enabled: Boolean) {
|
||||||
|
viewModelScope.launch { userPreferences.setVerboseLogging(enabled) }
|
||||||
|
}
|
||||||
|
|
||||||
fun selectSource(id: Long) {
|
fun selectSource(id: Long) {
|
||||||
viewModelScope.launch { sourceRepository.selectSource(id) }
|
viewModelScope.launch { sourceRepository.selectSource(id) }
|
||||||
}
|
}
|
||||||
@@ -83,6 +104,14 @@ class SettingsViewModel(
|
|||||||
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun setSyncMode(mode: SyncMode) {
|
||||||
|
viewModelScope.launch { userPreferences.setSyncMode(mode) }
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setSyncIntervalMinutes(value: Int) {
|
||||||
|
viewModelScope.launch { userPreferences.setSyncIntervalMinutes(value) }
|
||||||
|
}
|
||||||
|
|
||||||
fun setScrobbling(enabled: Boolean) {
|
fun setScrobbling(enabled: Boolean) {
|
||||||
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Pesquisar</string>
|
<string name="search_input_placeholder">Pesquisar</string>
|
||||||
<string name="settings_about_actions_licenses">Licenças</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_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_actions_support">Apoie o desenvolvedor</string>
|
||||||
<string name="settings_about_name">Sobre</string>
|
<string name="settings_about_name">Sobre</string>
|
||||||
<string name="settings_about_version">Versão %1$s</string>
|
<string name="settings_about_version">Versão %1$s</string>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<string name="search_input_placeholder">搜索</string>
|
<string name="search_input_placeholder">搜索</string>
|
||||||
<string name="settings_about_actions_licenses">许可</string>
|
<string name="settings_about_actions_licenses">许可</string>
|
||||||
<string name="settings_about_actions_project_homepage">项目地址</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_actions_support">支持开发者</string>
|
||||||
<string name="settings_about_name">关于</string>
|
<string name="settings_about_name">关于</string>
|
||||||
<string name="settings_about_version">版本 %1$s</string>
|
<string name="settings_about_version">版本 %1$s</string>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<string name="search_input_placeholder">搜尋</string>
|
<string name="search_input_placeholder">搜尋</string>
|
||||||
<string name="settings_about_actions_licenses">許可</string>
|
<string name="settings_about_actions_licenses">許可</string>
|
||||||
<string name="settings_about_actions_project_homepage">專案首頁</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_actions_support">支援開發者</string>
|
||||||
<string name="settings_about_name">關於</string>
|
<string name="settings_about_name">關於</string>
|
||||||
<string name="settings_about_version">版本 %1$s</string>
|
<string name="settings_about_version">版本 %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Suche</string>
|
<string name="search_input_placeholder">Suche</string>
|
||||||
<string name="settings_about_actions_licenses">Lizenzen</string>
|
<string name="settings_about_actions_licenses">Lizenzen</string>
|
||||||
<string name="settings_about_actions_project_homepage">Projektseite</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_actions_support">Den Entwickler unterstützen</string>
|
||||||
<string name="settings_about_name">Über</string>
|
<string name="settings_about_name">Über</string>
|
||||||
<string name="settings_about_version">Version %1$s</string>
|
<string name="settings_about_version">Version %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Buscar</string>
|
<string name="search_input_placeholder">Buscar</string>
|
||||||
<string name="settings_about_actions_licenses">Licencias</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_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_actions_support">Apoya al desarrollador</string>
|
||||||
<string name="settings_about_name">Información</string>
|
<string name="settings_about_name">Información</string>
|
||||||
<string name="settings_about_version">versión %1$s</string>
|
<string name="settings_about_version">versión %1$s</string>
|
||||||
|
|||||||
@@ -29,6 +29,7 @@
|
|||||||
<string name="search_input_placeholder">Bilatu</string>
|
<string name="search_input_placeholder">Bilatu</string>
|
||||||
<string name="settings_about_actions_licenses">Lizentziak</string>
|
<string name="settings_about_actions_licenses">Lizentziak</string>
|
||||||
<string name="settings_about_actions_project_homepage">Proiektuaren webgunea</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_actions_support">Lagundu garatzaileari</string>
|
||||||
<string name="settings_about_name">Honi buruz</string>
|
<string name="settings_about_name">Honi buruz</string>
|
||||||
<string name="settings_about_version">%1$s bertsioa</string>
|
<string name="settings_about_version">%1$s bertsioa</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Recherche</string>
|
<string name="search_input_placeholder">Recherche</string>
|
||||||
<string name="settings_about_actions_licenses">Licences</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_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_actions_support">Soutenir le développeur</string>
|
||||||
<string name="settings_about_name">À propos</string>
|
<string name="settings_about_name">À propos</string>
|
||||||
<string name="settings_about_version">version %1$s</string>
|
<string name="settings_about_version">version %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Buscar</string>
|
<string name="search_input_placeholder">Buscar</string>
|
||||||
<string name="settings_about_actions_licenses">Licenzas</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_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_actions_support">Axuda ao desenvolvemento</string>
|
||||||
<string name="settings_about_name">Acerca de</string>
|
<string name="settings_about_name">Acerca de</string>
|
||||||
<string name="settings_about_version">versión %1$s</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="search_input_placeholder">Keresés</string>
|
||||||
<string name="settings_about_actions_licenses">Licenszek</string>
|
<string name="settings_about_actions_licenses">Licenszek</string>
|
||||||
<string name="settings_about_actions_project_homepage">Projekt weboldal</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_actions_support">Támogasd a fejlesztőt</string>
|
||||||
<string name="settings_about_name">Az alkalmazásról</string>
|
<string name="settings_about_name">Az alkalmazásról</string>
|
||||||
<string name="settings_about_version">%1$s verzió</string>
|
<string name="settings_about_version">%1$s verzió</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Ricerca</string>
|
<string name="search_input_placeholder">Ricerca</string>
|
||||||
<string name="settings_about_actions_licenses">Licenze</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_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_actions_support">Sostieni lo sviluppatore</string>
|
||||||
<string name="settings_about_name">Informazioni</string>
|
<string name="settings_about_name">Informazioni</string>
|
||||||
<string name="settings_about_version">versione %1$s</string>
|
<string name="settings_about_version">versione %1$s</string>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<string name="search_input_placeholder">검색</string>
|
<string name="search_input_placeholder">검색</string>
|
||||||
<string name="settings_about_actions_licenses">저작권</string>
|
<string name="settings_about_actions_licenses">저작권</string>
|
||||||
<string name="settings_about_actions_project_homepage">프로젝트 홈페이지</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_actions_support">개발자 지원하기</string>
|
||||||
<string name="settings_about_name">subtracks에 대하여</string>
|
<string name="settings_about_name">subtracks에 대하여</string>
|
||||||
<string name="settings_about_version">버전: %1$s</string>
|
<string name="settings_about_version">버전: %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Søk</string>
|
<string name="search_input_placeholder">Søk</string>
|
||||||
<string name="settings_about_actions_licenses">Lisenser</string>
|
<string name="settings_about_actions_licenses">Lisenser</string>
|
||||||
<string name="settings_about_actions_project_homepage">Prosjekthjemmeside</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_actions_support">Støtt utvikleren</string>
|
||||||
<string name="settings_about_name">Om</string>
|
<string name="settings_about_name">Om</string>
|
||||||
<string name="settings_about_version">versjon %1$s</string>
|
<string name="settings_about_version">versjon %1$s</string>
|
||||||
|
|||||||
@@ -32,6 +32,7 @@
|
|||||||
<string name="search_input_placeholder">Поиск</string>
|
<string name="search_input_placeholder">Поиск</string>
|
||||||
<string name="settings_about_actions_licenses">Лицензии</string>
|
<string name="settings_about_actions_licenses">Лицензии</string>
|
||||||
<string name="settings_about_actions_project_homepage">Сайт проекта</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_actions_support">Поддержать разработчика</string>
|
||||||
<string name="settings_about_name">О Subtracks</string>
|
<string name="settings_about_name">О Subtracks</string>
|
||||||
<string name="settings_about_version">версия %1$s</string>
|
<string name="settings_about_version">версия %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Sök</string>
|
<string name="search_input_placeholder">Sök</string>
|
||||||
<string name="settings_about_actions_licenses">Licenser</string>
|
<string name="settings_about_actions_licenses">Licenser</string>
|
||||||
<string name="settings_about_actions_project_homepage">Projekthemsida</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_actions_support">Stöd utvecklaren</string>
|
||||||
<string name="settings_about_name">Om</string>
|
<string name="settings_about_name">Om</string>
|
||||||
<string name="settings_about_version">version %1$s</string>
|
<string name="settings_about_version">version %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">தேடல்</string>
|
<string name="search_input_placeholder">தேடல்</string>
|
||||||
<string name="settings_about_actions_licenses">உரிமங்கள்</string>
|
<string name="settings_about_actions_licenses">உரிமங்கள்</string>
|
||||||
<string name="settings_about_actions_project_homepage">திட்ட முகப்புப்பக்கம்</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_actions_support">டெவலப்பரை ஆதரிக்கவும்</string>
|
||||||
<string name="settings_about_name">பற்றி</string>
|
<string name="settings_about_name">பற்றி</string>
|
||||||
<string name="settings_about_version">பதிப்பு %1$s</string>
|
<string name="settings_about_version">பதிப்பு %1$s</string>
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
<string name="search_input_placeholder">ค้นหา</string>
|
<string name="search_input_placeholder">ค้นหา</string>
|
||||||
<string name="settings_about_actions_licenses">ใบอนุญาต</string>
|
<string name="settings_about_actions_licenses">ใบอนุญาต</string>
|
||||||
<string name="settings_about_actions_project_homepage">โฮมเพจของโครงการ</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_actions_support">ให้การสนับสนุนผู้พัฒนา</string>
|
||||||
<string name="settings_about_name">เกี่ยวกับ</string>
|
<string name="settings_about_name">เกี่ยวกับ</string>
|
||||||
<string name="settings_about_version">เวอร์ชั่น %1$s</string>
|
<string name="settings_about_version">เวอร์ชั่น %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Ara</string>
|
<string name="search_input_placeholder">Ara</string>
|
||||||
<string name="settings_about_actions_licenses">Lisanslar</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_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_actions_support">Geliştiriciyi destekleyin</string>
|
||||||
<string name="settings_about_name">Hakkında</string>
|
<string name="settings_about_name">Hakkında</string>
|
||||||
<string name="settings_about_version">sürüm %1$s</string>
|
<string name="settings_about_version">sürüm %1$s</string>
|
||||||
|
|||||||
@@ -30,6 +30,7 @@
|
|||||||
<string name="search_input_placeholder">Введіть заголовок/опис</string>
|
<string name="search_input_placeholder">Введіть заголовок/опис</string>
|
||||||
<string name="settings_about_actions_licenses">Ліцензії</string>
|
<string name="settings_about_actions_licenses">Ліцензії</string>
|
||||||
<string name="settings_about_actions_project_homepage">Головна сторінка проєкту</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_actions_support">Підтримайте розробника</string>
|
||||||
<string name="settings_about_name">Про застосунок</string>
|
<string name="settings_about_name">Про застосунок</string>
|
||||||
<string name="settings_about_version">версія %1$s</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="search_input_placeholder">Tìm kiếm</string>
|
||||||
<string name="settings_about_actions_licenses">Bản quyền</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_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_actions_support">Hỗ trợ nhà phát triển</string>
|
||||||
<string name="settings_about_name">Về ứng dụng</string>
|
<string name="settings_about_name">Về ứng dụng</string>
|
||||||
<string name="settings_about_version">phiên bản %1$s</string>
|
<string name="settings_about_version">phiên bản %1$s</string>
|
||||||
|
|||||||
@@ -2,6 +2,9 @@
|
|||||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||||
<string name="app_name">subtracks</string>
|
<string name="app_name">subtracks</string>
|
||||||
<string name="download_channel_description">Progress while songs download</string>
|
<string name="download_channel_description">Progress while songs download</string>
|
||||||
|
<string name="sync_notification_channel">Sync</string>
|
||||||
|
<string name="sync_notification_title">Syncing library</string>
|
||||||
|
<string name="sync_notification_text">Keeping your library up to date</string>
|
||||||
|
|
||||||
<string name="actions_cancel">Cancel</string>
|
<string name="actions_cancel">Cancel</string>
|
||||||
<string name="actions_delete">Delete</string>
|
<string name="actions_delete">Delete</string>
|
||||||
@@ -20,6 +23,7 @@
|
|||||||
<string name="controls_more">More</string>
|
<string name="controls_more">More</string>
|
||||||
|
|
||||||
<string name="navigation_back">Back</string>
|
<string name="navigation_back">Back</string>
|
||||||
|
<string name="scroll_to_top">Scroll to top</string>
|
||||||
<string name="navigation_tabs_home">Home</string>
|
<string name="navigation_tabs_home">Home</string>
|
||||||
<string name="navigation_tabs_library">Library</string>
|
<string name="navigation_tabs_library">Library</string>
|
||||||
<string name="navigation_tabs_settings">Settings</string>
|
<string name="navigation_tabs_settings">Settings</string>
|
||||||
@@ -57,8 +61,15 @@
|
|||||||
|
|
||||||
<string name="settings_about_actions_licenses">Licenses</string>
|
<string name="settings_about_actions_licenses">Licenses</string>
|
||||||
<string name="settings_about_actions_project_homepage">Project homepage</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_actions_support">Support the developer</string>
|
||||||
<string name="settings_about_name">About</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_about_version">Version %1$s</string>
|
||||||
<string name="settings_music_name">Playback</string>
|
<string name="settings_music_name">Playback</string>
|
||||||
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
<string name="settings_music_options_scrobble_description_on">Send now playing and play counts</string>
|
||||||
@@ -193,6 +204,21 @@
|
|||||||
<string name="sync_concurrency">Sync concurrency</string>
|
<string name="sync_concurrency">Sync concurrency</string>
|
||||||
<string name="sync_concurrency_parallel">Parallel server requests while syncing</string>
|
<string name="sync_concurrency_parallel">Parallel server requests while syncing</string>
|
||||||
<string name="sync_concurrency_sequential">1 (sequential)</string>
|
<string name="sync_concurrency_sequential">1 (sequential)</string>
|
||||||
|
<string name="settings_sync_auto_title">Sync automatically</string>
|
||||||
|
<string name="sync_mode_off">Off</string>
|
||||||
|
<string name="sync_mode_wifi">Wi-Fi only</string>
|
||||||
|
<string name="sync_mode_always">Always</string>
|
||||||
|
<string name="sync_mode_prompt">When to sync automatically</string>
|
||||||
|
<string name="sync_interval">Automatic sync interval</string>
|
||||||
|
<string name="sync_interval_prompt">How often to sync automatically</string>
|
||||||
|
<plurals name="sync_interval_minutes">
|
||||||
|
<item quantity="one">%1$d minute</item>
|
||||||
|
<item quantity="other">%1$d minutes</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="sync_interval_hours">
|
||||||
|
<item quantity="one">%1$d hour</item>
|
||||||
|
<item quantity="other">%1$d hours</item>
|
||||||
|
</plurals>
|
||||||
<string name="download_quality">Download quality</string>
|
<string name="download_quality">Download quality</string>
|
||||||
<string name="download_over_mobile">Download over mobile data</string>
|
<string name="download_over_mobile">Download over mobile data</string>
|
||||||
<string name="download_network_wifi_and_mobile">Wi-Fi and mobile</string>
|
<string name="download_network_wifi_and_mobile">Wi-Fi and mobile</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,67 @@
|
|||||||
|
package com.subtracks.data
|
||||||
|
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Rule
|
||||||
|
import org.junit.Test
|
||||||
|
import org.junit.rules.TemporaryFolder
|
||||||
|
import java.io.File
|
||||||
|
|
||||||
|
class LegacyDataCleanupTest {
|
||||||
|
@get:Rule
|
||||||
|
val tmp = TemporaryFolder()
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun removesThe1xLeftoversAndLeavesTheNativeData() {
|
||||||
|
val dataDir = tmp.newFolder("data")
|
||||||
|
val external = tmp.newFolder("external")
|
||||||
|
|
||||||
|
val legacyDatabase = file(dataDir, "databases/RKStorage")
|
||||||
|
val legacyDatabaseWal = file(dataDir, "databases/RKStorage-wal")
|
||||||
|
val legacyServers = file(dataDir, "files/servers/0.json")
|
||||||
|
val legacyCache = file(external, "s/0/coverArt/cover")
|
||||||
|
val nativeDatabase = file(dataDir, "databases/subtracks.db")
|
||||||
|
val nativeDownload = file(external, "Music/downloads/song.mp3")
|
||||||
|
|
||||||
|
LegacyDataCleanup.run(dataDir, external)
|
||||||
|
|
||||||
|
assertFalse(legacyDatabase.exists())
|
||||||
|
assertFalse(legacyDatabaseWal.exists())
|
||||||
|
assertFalse(legacyServers.exists())
|
||||||
|
assertFalse(legacyCache.exists())
|
||||||
|
assertTrue(nativeDatabase.exists())
|
||||||
|
assertTrue(nativeDownload.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun leavesTheNativeDataAloneOnAFreshInstall() {
|
||||||
|
val dataDir = tmp.newFolder("data2")
|
||||||
|
val external = tmp.newFolder("external2")
|
||||||
|
val nativeDatabase = file(dataDir, "databases/subtracks.db")
|
||||||
|
val nativeDownload = file(external, "Music/downloads/song.mp3")
|
||||||
|
|
||||||
|
LegacyDataCleanup.run(dataDir, external)
|
||||||
|
|
||||||
|
assertTrue(nativeDatabase.exists())
|
||||||
|
assertTrue(nativeDownload.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun cleansUpEvenWhenExternalStorageIsUnavailable() {
|
||||||
|
val dataDir = tmp.newFolder("data3")
|
||||||
|
val legacyDatabase = file(dataDir, "databases/RKStorage")
|
||||||
|
|
||||||
|
LegacyDataCleanup.run(dataDir, null)
|
||||||
|
|
||||||
|
assertFalse(legacyDatabase.exists())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun file(
|
||||||
|
dir: File,
|
||||||
|
path: String,
|
||||||
|
): File =
|
||||||
|
File(dir, path).apply {
|
||||||
|
parentFile?.mkdirs()
|
||||||
|
createNewFile()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
prefs.setLastSeed(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null))
|
||||||
assertEquals(ArtworkSeedValue("art:2", 0xFF778899.toInt(), null), prefs.lastSeed())
|
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()
|
file.delete()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,146 @@
|
|||||||
|
package com.subtracks.data.sync
|
||||||
|
|
||||||
|
import com.subtracks.awaitUntil
|
||||||
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
|
import com.subtracks.data.net.NetworkMode
|
||||||
|
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||||
|
import com.subtracks.data.prefs.SyncMode
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
|
import kotlinx.coroutines.CoroutineScope
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.SupervisorJob
|
||||||
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertFalse
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger
|
||||||
|
|
||||||
|
class AutoSyncTest {
|
||||||
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
cancelAndJoinBlocking(scope)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun decidesWhetherASyncIsDue() {
|
||||||
|
assertTrue(shouldSync())
|
||||||
|
assertTrue(shouldSync(mode = SyncMode.WifiOnly))
|
||||||
|
assertTrue(shouldSync(mode = SyncMode.Always, network = NetworkMode.Mobile))
|
||||||
|
assertTrue(shouldSync(lastSyncAt = INTERVAL_MS * 2, now = INTERVAL_MS))
|
||||||
|
|
||||||
|
assertFalse(shouldSync(mode = SyncMode.Off))
|
||||||
|
assertFalse(shouldSync(mode = SyncMode.WifiOnly, network = NetworkMode.Mobile))
|
||||||
|
assertFalse(shouldSync(lastSyncAt = INTERVAL_MS - 1, now = INTERVAL_MS))
|
||||||
|
assertFalse(shouldSync(offline = true))
|
||||||
|
assertFalse(shouldSync(hasSource = false))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun syncsWhenTheAppIsActiveAndDue() {
|
||||||
|
val preferences = fakeUserPreferences()
|
||||||
|
runBlocking {
|
||||||
|
preferences.setSyncMode(SyncMode.WifiOnly)
|
||||||
|
preferences.setLastSyncAt(0L)
|
||||||
|
}
|
||||||
|
val requested = AtomicBoolean(false)
|
||||||
|
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
|
||||||
|
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
|
||||||
|
awaitUntil("auto sync requested") { requested.get() }
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotSyncWhenOff() {
|
||||||
|
val preferences = fakeUserPreferences()
|
||||||
|
runBlocking {
|
||||||
|
preferences.setSyncMode(SyncMode.Off)
|
||||||
|
preferences.setLastSyncAt(0L)
|
||||||
|
}
|
||||||
|
val requested = AtomicBoolean(false)
|
||||||
|
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
|
||||||
|
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
runBlocking { delay(300) }
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
|
||||||
|
assertFalse(requested.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun stopsSyncingWhenBackgrounded() {
|
||||||
|
val preferences = fakeUserPreferences()
|
||||||
|
runBlocking {
|
||||||
|
preferences.setSyncMode(SyncMode.Always)
|
||||||
|
preferences.setLastSyncAt(0L)
|
||||||
|
}
|
||||||
|
val count = AtomicInteger(0)
|
||||||
|
val autoSync =
|
||||||
|
AutoSync(
|
||||||
|
preferences = preferences,
|
||||||
|
networkMode = flowOf(NetworkMode.Wifi),
|
||||||
|
hasSource = { true },
|
||||||
|
requestSync = { count.incrementAndGet() },
|
||||||
|
scope = scope,
|
||||||
|
now = { INTERVAL_MS + 1 },
|
||||||
|
maxPollMs = 50L,
|
||||||
|
)
|
||||||
|
|
||||||
|
autoSync.setForeground(true)
|
||||||
|
awaitUntil("first sync") { count.get() >= 1 }
|
||||||
|
autoSync.setForeground(false)
|
||||||
|
|
||||||
|
runBlocking { delay(200) }
|
||||||
|
val afterStop = count.get()
|
||||||
|
runBlocking { delay(300) }
|
||||||
|
|
||||||
|
assertEquals(afterStop, count.get())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun autoSync(
|
||||||
|
preferences: UserPreferences,
|
||||||
|
requestSync: () -> Unit,
|
||||||
|
now: () -> Long = { INTERVAL_MS + 1 },
|
||||||
|
): AutoSync =
|
||||||
|
AutoSync(
|
||||||
|
preferences = preferences,
|
||||||
|
networkMode = flowOf(NetworkMode.Wifi),
|
||||||
|
hasSource = { true },
|
||||||
|
requestSync = requestSync,
|
||||||
|
scope = scope,
|
||||||
|
now = now,
|
||||||
|
maxPollMs = 10_000L,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun shouldSync(
|
||||||
|
mode: SyncMode = SyncMode.WifiOnly,
|
||||||
|
network: NetworkMode = NetworkMode.Wifi,
|
||||||
|
offline: Boolean = false,
|
||||||
|
hasSource: Boolean = true,
|
||||||
|
lastSyncAt: Long = 0L,
|
||||||
|
now: Long = INTERVAL_MS,
|
||||||
|
intervalMs: Long = INTERVAL_MS,
|
||||||
|
): Boolean =
|
||||||
|
shouldAutoSync(
|
||||||
|
mode = mode,
|
||||||
|
network = network,
|
||||||
|
offline = offline,
|
||||||
|
hasSource = hasSource,
|
||||||
|
lastSyncAt = lastSyncAt,
|
||||||
|
now = now,
|
||||||
|
intervalMs = intervalMs,
|
||||||
|
)
|
||||||
|
|
||||||
|
private companion object {
|
||||||
|
val INTERVAL_MS = DEFAULT_SYNC_INTERVAL_MINUTES * 60_000L
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,6 +10,7 @@ import com.subtracks.TEST_TIMEOUT_MS
|
|||||||
import com.subtracks.cancelAndJoinBlocking
|
import com.subtracks.cancelAndJoinBlocking
|
||||||
import com.subtracks.data.db.SubtracksDatabase
|
import com.subtracks.data.db.SubtracksDatabase
|
||||||
import com.subtracks.data.download.ArtworkStore
|
import com.subtracks.data.download.ArtworkStore
|
||||||
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.prefs.fakeUserPreferences
|
import com.subtracks.data.prefs.fakeUserPreferences
|
||||||
import com.subtracks.data.repo.NetworkServerActionSink
|
import com.subtracks.data.repo.NetworkServerActionSink
|
||||||
import com.subtracks.data.repo.QueueRepository
|
import com.subtracks.data.repo.QueueRepository
|
||||||
@@ -44,6 +45,7 @@ class SyncManagerTest {
|
|||||||
private lateinit var sourceRepository: SourceRepository
|
private lateinit var sourceRepository: SourceRepository
|
||||||
private lateinit var queueRepository: QueueRepository
|
private lateinit var queueRepository: QueueRepository
|
||||||
private lateinit var manager: SyncManager
|
private lateinit var manager: SyncManager
|
||||||
|
private lateinit var preferences: UserPreferences
|
||||||
private lateinit var scope: CoroutineScope
|
private lateinit var scope: CoroutineScope
|
||||||
private val messages = CopyOnWriteArrayList<String>()
|
private val messages = CopyOnWriteArrayList<String>()
|
||||||
private lateinit var server: MockWebServer
|
private lateinit var server: MockWebServer
|
||||||
@@ -62,12 +64,14 @@ class SyncManagerTest {
|
|||||||
sourceRepository =
|
sourceRepository =
|
||||||
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
|
||||||
queueRepository = QueueRepository(db)
|
queueRepository = QueueRepository(db)
|
||||||
|
preferences = fakeUserPreferences()
|
||||||
manager =
|
manager =
|
||||||
SyncManager(
|
SyncManager(
|
||||||
db,
|
db,
|
||||||
sourceRepository,
|
sourceRepository,
|
||||||
queueRepository,
|
queueRepository,
|
||||||
NetworkServerActionSink(sourceRepository),
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
preferences = preferences,
|
||||||
showMessage = { messages += it.resolve(resources) },
|
showMessage = { messages += it.resolve(resources) },
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
@@ -89,6 +93,16 @@ class SyncManagerTest {
|
|||||||
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
assertEquals(listOf("No server configured"), messages)
|
assertEquals(listOf("No server configured"), messages)
|
||||||
|
assertTrue(preferences.lastSyncAt().first() > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aSilentSyncDoesNotTellTheUser() =
|
||||||
|
runBlocking {
|
||||||
|
manager.requestSync(silent = true)
|
||||||
|
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
|
||||||
|
|
||||||
|
assertTrue(messages.isEmpty())
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -114,6 +128,7 @@ class SyncManagerTest {
|
|||||||
|
|
||||||
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
|
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
|
||||||
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
|
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
|
||||||
|
assertTrue(preferences.lastSyncAt().first() > 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -2,7 +2,12 @@ package com.subtracks.playback
|
|||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
|
import androidx.media3.common.C
|
||||||
|
import androidx.media3.database.StandaloneDatabaseProvider
|
||||||
|
import androidx.media3.datasource.DataSource
|
||||||
import androidx.media3.datasource.DataSpec
|
import androidx.media3.datasource.DataSpec
|
||||||
|
import androidx.media3.datasource.cache.NoOpCacheEvictor
|
||||||
|
import androidx.media3.datasource.cache.SimpleCache
|
||||||
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
import androidx.media3.datasource.okhttp.OkHttpDataSource
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
@@ -13,13 +18,16 @@ import okhttp3.mockwebserver.MockWebServer
|
|||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
import java.nio.charset.StandardCharsets
|
||||||
|
|
||||||
@RunWith(AndroidJUnit4::class)
|
@RunWith(AndroidJUnit4::class)
|
||||||
class MediaDataSourceTest {
|
class MediaDataSourceTest {
|
||||||
private val context = ApplicationProvider.getApplicationContext<Context>()
|
private val context = ApplicationProvider.getApplicationContext<Context>()
|
||||||
|
|
||||||
private fun dataSource() = mediaDataSourceFactory(context, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
|
private fun dataSource(cache: SimpleCache? = null) =
|
||||||
|
mediaDataSourceFactory(context, cache, OkHttpDataSource.Factory(OkHttpClient())).createDataSource()
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aDownloadedFileIsReadThroughTheStreamingChain() {
|
fun aDownloadedFileIsReadThroughTheStreamingChain() {
|
||||||
@@ -50,4 +58,44 @@ class MediaDataSourceTest {
|
|||||||
|
|
||||||
assertEquals(1_000_000L, length)
|
assertEquals(1_000_000L, length)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun reopeningAStreamIsServedFromTheCache() {
|
||||||
|
val server = MockWebServer()
|
||||||
|
server.enqueue(MockResponse().setBody("0123456789"))
|
||||||
|
server.start()
|
||||||
|
val url = "http://${server.hostName}:${server.port}/rest/stream"
|
||||||
|
val cache =
|
||||||
|
SimpleCache(
|
||||||
|
File(context.cacheDir, "stream-cache-${System.nanoTime()}"),
|
||||||
|
NoOpCacheEvictor(),
|
||||||
|
StandaloneDatabaseProvider(context),
|
||||||
|
)
|
||||||
|
|
||||||
|
val first = readAll(dataSource(cache), url)
|
||||||
|
val second = readAll(dataSource(cache), url)
|
||||||
|
val requests = server.requestCount
|
||||||
|
server.shutdown()
|
||||||
|
cache.release()
|
||||||
|
|
||||||
|
assertEquals("0123456789", String(first, StandardCharsets.UTF_8))
|
||||||
|
assertEquals(first.toList(), second.toList())
|
||||||
|
assertEquals(1, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAll(
|
||||||
|
dataSource: DataSource,
|
||||||
|
url: String,
|
||||||
|
): ByteArray {
|
||||||
|
dataSource.open(DataSpec(Uri.parse(url)))
|
||||||
|
val out = ByteArrayOutputStream()
|
||||||
|
val buffer = ByteArray(1024)
|
||||||
|
while (true) {
|
||||||
|
val read = dataSource.read(buffer, 0, buffer.size)
|
||||||
|
if (read == C.RESULT_END_OF_INPUT) break
|
||||||
|
out.write(buffer, 0, read)
|
||||||
|
}
|
||||||
|
dataSource.close()
|
||||||
|
return out.toByteArray()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import android.content.Context
|
|||||||
import android.content.res.Resources
|
import android.content.res.Resources
|
||||||
import androidx.media3.common.PlaybackException
|
import androidx.media3.common.PlaybackException
|
||||||
import androidx.room3.Room
|
import androidx.room3.Room
|
||||||
|
import androidx.room3.useWriterConnection
|
||||||
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
import androidx.sqlite.driver.bundled.BundledSQLiteDriver
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||||
@@ -2391,6 +2392,61 @@ class PlaybackControllerTest {
|
|||||||
assertEquals(setOf("a1", "a2", "a3", "b1", "b2"), handle.items.map { it.id }.toSet())
|
assertEquals(setOf("a1", "a2", "a3", "b1", "b2"), handle.items.map { it.id }.toSet())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffleGenrePlaysOnlyThatGenresSongs() {
|
||||||
|
seedGenre()
|
||||||
|
|
||||||
|
controller.shuffleGenre(1, "Rock")
|
||||||
|
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||||
|
|
||||||
|
assertTrue(controller.state.value.shuffle)
|
||||||
|
assertEquals(
|
||||||
|
QueueKind.Genre,
|
||||||
|
controller.state.value.context
|
||||||
|
?.kind,
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
"Rock",
|
||||||
|
controller.state.value.context
|
||||||
|
?.refId,
|
||||||
|
)
|
||||||
|
assertEquals(setOf("r1", "r2"), handle.items.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffleStarredPlaysOnlyStarredSongs() {
|
||||||
|
seedStarred()
|
||||||
|
|
||||||
|
controller.shuffleStarred(1)
|
||||||
|
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||||
|
|
||||||
|
assertTrue(controller.state.value.shuffle)
|
||||||
|
assertEquals(
|
||||||
|
QueueKind.Starred,
|
||||||
|
controller.state.value.context
|
||||||
|
?.kind,
|
||||||
|
)
|
||||||
|
assertEquals(setOf("t1", "t2"), handle.items.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun shuffleDownloadedPlaysOnlyDownloadedSongs() {
|
||||||
|
seedAlbum(3, sourceId = 1)
|
||||||
|
markDownloaded(1, "s1")
|
||||||
|
markDownloaded(1, "s3")
|
||||||
|
|
||||||
|
controller.shuffleDownloaded(1)
|
||||||
|
await { controller.state.value.item != null && controller.state.value.shuffle }
|
||||||
|
|
||||||
|
assertTrue(controller.state.value.shuffle)
|
||||||
|
assertEquals(
|
||||||
|
QueueKind.Downloaded,
|
||||||
|
controller.state.value.context
|
||||||
|
?.kind,
|
||||||
|
)
|
||||||
|
assertEquals(setOf("s1", "s3"), handle.items.map { it.id }.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun artistQueueRowsFollowTheFlatShuffleIdOrder() {
|
fun artistQueueRowsFollowTheFlatShuffleIdOrder() {
|
||||||
seedArtist()
|
seedArtist()
|
||||||
@@ -3100,6 +3156,39 @@ class PlaybackControllerTest {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun seedGenre() {
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
Song(1, "r1", "al1", "ar1", "Rock 1", "Album", "Artist", 100, 1, 1, null, null),
|
||||||
|
Song(1, "r2", "al1", "ar1", "Rock 2", "Album", "Artist", 100, 2, 1, null, null),
|
||||||
|
Song(1, "j1", "al1", "ar1", "Jazz 1", "Album", "Artist", 100, 3, 1, null, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
db.useWriterConnection { connection ->
|
||||||
|
listOf("r1" to "Rock", "r2" to "Rock", "j1" to "Jazz").forEach { (songId, genre) ->
|
||||||
|
connection.usePrepared(
|
||||||
|
"INSERT INTO song_genres (sourceId, songId, position, genre) VALUES (1, '$songId', 0, '$genre')",
|
||||||
|
) { it.step() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun seedStarred() {
|
||||||
|
runBlocking {
|
||||||
|
db.sourcesDao().upsertSource(Source(1, "source 1", "http://localhost:1", true, 1))
|
||||||
|
db.libraryDao().upsertSongs(
|
||||||
|
listOf(
|
||||||
|
Song(1, "t1", "al1", "ar1", "Star 1", "Album", "Artist", 100, 1, 1, 1_700_000_000L, null),
|
||||||
|
Song(1, "t2", "al1", "ar1", "Star 2", "Album", "Artist", 100, 2, 1, 1_700_000_000L, null),
|
||||||
|
Song(1, "n1", "al1", "ar1", "Plain 1", "Album", "Artist", 100, 3, 1, null, null),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun seedAlbumTracks(
|
private fun seedAlbumTracks(
|
||||||
albumId: String,
|
albumId: String,
|
||||||
count: Int,
|
count: Int,
|
||||||
|
|||||||
@@ -130,15 +130,54 @@ class ScrobblePolicyTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun restartingTheSameTrackScrobblesAgain() {
|
fun restartingTheSameTrackResubmitsWithoutReannouncing() {
|
||||||
val item = track(durationMs = 200_000)
|
val item = track(durationMs = 200_000)
|
||||||
play(item, toPositionMs = 100_000)
|
play(item, toPositionMs = 100_000)
|
||||||
clock = 120_000L
|
clock = 120_000L
|
||||||
val repeat = play(item, toPositionMs = 100_000)
|
val repeat = play(item, toPositionMs = 100_000)
|
||||||
assertEquals(Scrobble.NowPlaying("s1"), repeat.first())
|
assertEquals(emptyList<Scrobble>(), repeat.filterIsInstance<Scrobble.NowPlaying>())
|
||||||
assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last())
|
assertEquals(Scrobble.Submission("s1", 120_000L), repeat.last())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotReannounceTheTrackItJustLeftWhenItsPositionResets() {
|
||||||
|
val events = mutableListOf<Scrobble>()
|
||||||
|
events += play(track(id = "s1"), toPositionMs = 100_000)
|
||||||
|
events +=
|
||||||
|
listOfNotNull(
|
||||||
|
policy.advance(track(id = "s1"), 200_000, isPlaying = true, positionMs = 0),
|
||||||
|
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(Scrobble.NowPlaying("s1"), Scrobble.Submission("s1", 1_000L), Scrobble.NowPlaying("s2")),
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun doesNotReannounceWhenTheNewTracksPositionJittersBackToZero() {
|
||||||
|
val events = mutableListOf<Scrobble>()
|
||||||
|
events += play(track(id = "s1"), toPositionMs = 100_000)
|
||||||
|
events +=
|
||||||
|
listOfNotNull(
|
||||||
|
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0),
|
||||||
|
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 500),
|
||||||
|
policy.advance(track(id = "s2"), 200_000, isPlaying = true, positionMs = 0),
|
||||||
|
)
|
||||||
|
assertEquals(
|
||||||
|
listOf(Scrobble.NowPlaying("s1"), Scrobble.Submission("s1", 1_000L), Scrobble.NowPlaying("s2")),
|
||||||
|
events,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun announcesWhenATrackLoadedWhilePausedStartsFromTheBeginning() {
|
||||||
|
val item = track(id = "s1")
|
||||||
|
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 0))
|
||||||
|
assertNull(policy.advance(item, 200_000, isPlaying = false, positionMs = 2_500))
|
||||||
|
assertEquals(Scrobble.NowPlaying("s1"), policy.advance(item, 200_000, isPlaying = true, positionMs = 0))
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() {
|
fun aTransitionDoesNotReannounceTheNewTrackOnAStaleLowPosition() {
|
||||||
val first = track(id = "s1")
|
val first = track(id = "s1")
|
||||||
|
|||||||
@@ -190,4 +190,34 @@ class ScrobblerTest {
|
|||||||
enabled.value = true
|
enabled.value = true
|
||||||
assertEquals(listOf(Call.NowPlaying("s1")), sink.calls)
|
assertEquals(listOf(Call.NowPlaying("s1")), sink.calls)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun aTransitionSendsNowPlayingOnceForTheNewTrack() =
|
||||||
|
runTest {
|
||||||
|
val sink = RecordingSink()
|
||||||
|
val scrobbler = Scrobbler(sink = sink, enabled = flowOf(true), scope = testScope(), policy = ScrobblePolicy(now = { 1_000L }))
|
||||||
|
val state = MutableStateFlow(PlaybackState())
|
||||||
|
val position = MutableStateFlow(0L)
|
||||||
|
scrobbler.attach(state, position)
|
||||||
|
val first = QueueItem("s1", "One", "Artist", "Album", null, durationMs = 30_000)
|
||||||
|
val second = QueueItem("s2", "Two", "Artist", "Album", null, durationMs = 30_000)
|
||||||
|
|
||||||
|
state.value = PlaybackState(item = first, isPlaying = true)
|
||||||
|
var tick = 500L
|
||||||
|
while (tick <= 15_000L) {
|
||||||
|
position.value = tick
|
||||||
|
tick += 500
|
||||||
|
}
|
||||||
|
position.value = 0L
|
||||||
|
state.value = PlaybackState(item = second, isPlaying = true)
|
||||||
|
|
||||||
|
assertEquals(
|
||||||
|
listOf(
|
||||||
|
Call.NowPlaying("s1"),
|
||||||
|
Call.Scrobble("s1", 1_000L),
|
||||||
|
Call.NowPlaying("s2"),
|
||||||
|
),
|
||||||
|
sink.calls,
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -362,6 +362,7 @@ class AppScreenshotTest {
|
|||||||
onAddServer = {},
|
onAddServer = {},
|
||||||
onOpenDownloads = {},
|
onOpenDownloads = {},
|
||||||
onOpenLicenses = {},
|
onOpenLicenses = {},
|
||||||
|
onShareLogs = {},
|
||||||
onBack = {},
|
onBack = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -505,7 +506,7 @@ class AppScreenshotTest {
|
|||||||
onPlay = {},
|
onPlay = {},
|
||||||
onRemove = {},
|
onRemove = {},
|
||||||
onReorder = { _, _ -> },
|
onReorder = { _, _ -> },
|
||||||
onMove = { _, _ -> },
|
onMove = { _, _ -> true },
|
||||||
onLoadOlder = {},
|
onLoadOlder = {},
|
||||||
onLoadNewer = {},
|
onLoadNewer = {},
|
||||||
onUndo = {},
|
onUndo = {},
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,7 @@ import androidx.compose.foundation.lazy.LazyColumn
|
|||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
import androidx.compose.ui.layout.onSizeChanged
|
||||||
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule
|
||||||
@@ -55,4 +56,33 @@ class ViewportFillTest {
|
|||||||
|
|
||||||
assertEquals(initial, fillHeight)
|
assertEquals(initial, fillHeight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun scrollToTopOnRequestMovesOnlyOnANewRequest() {
|
||||||
|
lateinit var state: LazyListState
|
||||||
|
val request = mutableStateOf<Any?>(null)
|
||||||
|
composeRule.setContent {
|
||||||
|
SubtracksTheme {
|
||||||
|
state = rememberLazyListState()
|
||||||
|
ScrollToTopOnRequest(request.value, state)
|
||||||
|
LazyColumn(state = state, modifier = Modifier.fillMaxSize()) {
|
||||||
|
items(50) { Text("item $it", modifier = Modifier.height(56.dp)) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
|
||||||
|
composeRule.runOnIdle { runBlocking { state.scrollToItem(10) } }
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
assertEquals(10, state.firstVisibleItemIndex)
|
||||||
|
|
||||||
|
composeRule.runOnIdle { request.value = 0 }
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
assertEquals("selecting a page must keep its scroll position", 10, state.firstVisibleItemIndex)
|
||||||
|
|
||||||
|
composeRule.runOnIdle { request.value = 1 }
|
||||||
|
composeRule.mainClock.advanceTimeBy(2_000)
|
||||||
|
composeRule.waitForIdle()
|
||||||
|
assertEquals("a new request should scroll back to the top", 0, state.firstVisibleItemIndex)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -325,6 +325,42 @@ class QueueViewModelTest {
|
|||||||
assertEquals((220L..399L).toList(), rowsSnapshot().map { it.position })
|
assertEquals((220L..399L).toList(), rowsSnapshot().map { it.position })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun ignoringASecondMoveWhileTheFirstIsStillCommitting() {
|
||||||
|
runBlocking { seedSongs(10) }
|
||||||
|
controller.playAlbum(1, "al1", 0)
|
||||||
|
await { controller.state.value.position == 0L }
|
||||||
|
|
||||||
|
viewModel.open()
|
||||||
|
await { viewModel.ready }
|
||||||
|
val before = rowsSnapshot().map { it.song.song.id }
|
||||||
|
val from = rowsSnapshot().first().position
|
||||||
|
|
||||||
|
runOnMain {
|
||||||
|
assertTrue(viewModel.move(from, from + 3))
|
||||||
|
assertFalse(viewModel.move(from, from + 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
val expected = before.toMutableList().also { it.add(3, it.removeAt(0)) }
|
||||||
|
await { rowsSnapshot().map { it.song.song.id } == expected }
|
||||||
|
assertEquals(expected, rowsSnapshot().map { it.song.song.id })
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun dropMoveTakesTheDraggedRowsCurrentPosition() {
|
||||||
|
val rows =
|
||||||
|
listOf(
|
||||||
|
row(10).copy(id = 2L),
|
||||||
|
row(11).copy(id = 3L),
|
||||||
|
row(9).copy(id = 1L),
|
||||||
|
)
|
||||||
|
|
||||||
|
val move = rows.dropMoveFor(1L)!!
|
||||||
|
|
||||||
|
assertEquals(9L, move.from)
|
||||||
|
assertEquals(11L, move.to)
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun removingATrackUnderTheBlockReloadsRows() {
|
fun removingATrackUnderTheBlockReloadsRows() {
|
||||||
runBlocking { seedSongs(10) }
|
runBlocking { seedSongs(10) }
|
||||||
|
|||||||
@@ -156,7 +156,14 @@ class AddSourceViewModelTest {
|
|||||||
|
|
||||||
private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
|
private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
|
||||||
val syncManager =
|
val syncManager =
|
||||||
SyncManager(db, sourceRepository, QueueRepository(db), NetworkServerActionSink(sourceRepository), scope = repoScope)
|
SyncManager(
|
||||||
|
db,
|
||||||
|
sourceRepository,
|
||||||
|
QueueRepository(db),
|
||||||
|
NetworkServerActionSink(sourceRepository),
|
||||||
|
preferences = fakeUserPreferences(),
|
||||||
|
scope = repoScope,
|
||||||
|
)
|
||||||
val resources = ApplicationProvider.getApplicationContext<Context>().resources
|
val resources = ApplicationProvider.getApplicationContext<Context>().resources
|
||||||
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it }
|
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ class LicensesScreenTest {
|
|||||||
onAddServer = {},
|
onAddServer = {},
|
||||||
onOpenDownloads = {},
|
onOpenDownloads = {},
|
||||||
onOpenLicenses = { opened = true },
|
onOpenLicenses = { opened = true },
|
||||||
|
onShareLogs = {},
|
||||||
onBack = {},
|
onBack = {},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ app/src/main/kotlin/com/subtracks
|
|||||||
repo/ SourceRepository, LibraryRepository
|
repo/ SourceRepository, LibraryRepository
|
||||||
source/ MusicSource abstraction
|
source/ MusicSource abstraction
|
||||||
subsonic/ SubsonicClient, SubsonicXml, SubsonicSource
|
subsonic/ SubsonicClient, SubsonicXml, SubsonicSource
|
||||||
sync/ SyncService (stream + per-batch writes + prune), SyncManager (status)
|
sync/ SyncService (stream + per-batch writes + prune), SyncManager (status), AutoSync (foreground timer), SyncNotifier
|
||||||
```
|
```
|
||||||
|
|
||||||
## Layers
|
## Layers
|
||||||
@@ -65,12 +65,12 @@ Compose UI -> ViewModel -> Repository -> MusicSource (remote, Subsonic)
|
|||||||
- The remote side sits behind `MusicSource` so additional backends can be added later.
|
- The remote side sits behind `MusicSource` so additional backends can be added later.
|
||||||
- `SourceRepository` owns the configured servers and builds a `SubsonicSource` from the active one; it also serves cover-art URLs. `LibraryRepository` reads the local mirror.
|
- `SourceRepository` owns the configured servers and builds a `SubsonicSource` from the active one; it also serves cover-art URLs. `LibraryRepository` reads the local mirror.
|
||||||
- The local side is Room; reads are `Flow`s, so the UI updates as sync writes.
|
- The local side is Room; reads are `Flow`s, so the UI updates as sync writes.
|
||||||
- `SyncManager` triggers syncs off the UI (its own scope) and exposes a `StateFlow<SyncStatus>`; `SyncService` does the actual work.
|
- `SyncManager` triggers syncs off the UI (its own scope) and exposes a `StateFlow<SyncStatus>`; `SyncService` does the actual work. `AutoSync` runs a foreground-only timer that asks `SyncManager` for a silent sync once the configured interval has elapsed.
|
||||||
- ViewModels are wired with Koin; screens are split into a stateful `*Route` (default `koinViewModel()`) and a stateless `*Screen` so the latter can be screenshot-tested directly.
|
- ViewModels are wired with Koin; screens are split into a stateful `*Route` (default `koinViewModel()`) and a stateless `*Screen` so the latter can be screenshot-tested directly.
|
||||||
|
|
||||||
## UI shell
|
## UI shell
|
||||||
|
|
||||||
The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The sync action doubles as the progress indicator while a sync runs. There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition.
|
The library is one `LibraryScreen` with a pinned top bar: the current section as the title, a custom row of icon buttons (Albums, Artists, Playlists) on the left, and Sync and Settings icon actions on the right. The selected section is drawn as a filled box in the content colour with the icon inverted, like the Flutter app — this is a hand-rolled row, not Material `TabRow` (no underline indicator). Icons use the Material rounded variants. Tabs switch the content in place; album and playlist rows push a detail screen, and Settings is pushed from the top bar. The library syncs on pull-to-refresh; the pull indicator holds only while a manual sync runs, and a spinner rings the Settings action whenever any sync is running (an automatic sync shows only the spinner, plus a notification). There is no bottom navigation. A mini player sits above the navigation bar across every screen whenever a track is loaded. It is a permanent part of the app shell (the screens above it consume the navigation-bar inset so nothing is padded twice); the Now Playing screen slides up over the whole shell as an overlay rather than being a nav destination, so the mini player never re-lays out during the transition.
|
||||||
|
|
||||||
The Albums tab is a three-column grid of covers only. Sorting and filtering hang off a FAB, and Settings holds the server list and the network, playback, download and about sections rather than list sort or sync.
|
The Albums tab is a three-column grid of covers only. Sorting and filtering hang off a FAB, and Settings holds the server list and the network, playback, download and about sections rather than list sort or sync.
|
||||||
|
|
||||||
@@ -88,7 +88,7 @@ Playback is a *context* plus a manual *up next* list. `queue_entries` is the con
|
|||||||
|
|
||||||
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
|
Because the resolver reports *which entry* a position falls in, `PlaybackState` also carries the current queue context (`kind` and `refId`). That is what scopes the playing-track indicator in the album and playlist lists: a song that appears in both only lights up in the list the queue was actually started from.
|
||||||
|
|
||||||
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances.
|
Stream URLs come from `SourceRepository.streamUri`, which asks the active `SubsonicSource` for a freshly salted `stream` URL; because the URL changes every request, artwork is attached to the media item by identity (the `CoverArtRef` cache key) rather than by URL. The active source carries the stream quality (a max bitrate plus an optional transcode format) for the current network: `NetworkMonitor` reports Wi-Fi vs mobile (metered) and `UserPreferences` stores a separate `StreamQuality` per mode, so `SourceRepository` rebuilds the source when either the mode or a quality changes. `PlaybackController` observes that effective quality and reloads the window around the current position, preserving play/pause and the playback position, so a Wi-Fi↔mobile transition re-issues the stream URLs with the new settings (a no-op when the two modes share a quality). Transcoded responses are chunked with no length and (because ffmpeg cannot seek back in a stream) no MP3 duration header, so Media3 would treat them as live and ignore seeks; the server's `estimateContentLength` is no better, since its estimate under-counts and cuts the track short. So a transcoded URL carries a declared upper-bound length in its fragment (never sent to the server), `KnownLengthDataSource` hands that length to Media3 to keep the stream seekable without truncation, and each transcoded item is clipped to the library duration so the timeline ends where the audio does and the next track still advances. Streamed bytes also pass through a bounded `SimpleCache` (`CacheDataSource` sits under `DefaultDataSource`, so downloads bypass it): a headerless MP3's duration is only estimated, and when the extractor rewrites it at end of stream Media3 drops and re-opens the prefetched next period. The cache serves the bytes that prefetch already buffered from disk, so the re-open does not re-fetch them (only bytes the cancelled prefetch never committed are fetched again). Keys are the salted stream URLs, so nothing is reused across a service restart; it is a bounded scratch buffer, not a persistent download.
|
||||||
|
|
||||||
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
|
The window is also the queue the media notification browses — a deliberate trade-off, since the alternative is holding an entire large library in memory. The queue view resolves positions lazily too: it loads the queue around the cursor in chunks and extends by a chunk when you scroll near either end, so it never materializes the whole queue. Dragging uses `sh.calvin.reorderable`, which floats the lifted row and reorders the loaded list live (animating with `animateItem`), so editing is what a drag needs: the view reorders its loaded list while dragging and commits one move on drop. `queue_entries` is rewritten to edit: removing a position splits its entry into up to two ranges, and moving one turns the moved track into an explicit song entry inserted at the target index, so an edit costs a couple of rows rather than a copy of the queue. When the edit falls inside the player's window the controller mirrors it with `removeMediaItem`/`moveMediaItem` so playback is not interrupted; removing the playing track, or a move that crosses the window boundary, reloads the window around the cursor. The controller keeps the pre-edit entries and cursor, so the queue view can offer a one-step undo. The up-next block is fully editable in the same view (drag reorder and delete), while the context is read-only while shuffled. Timed lyrics are not wired yet.
|
||||||
|
|
||||||
@@ -116,7 +116,9 @@ Images: grids and lists request a 256 px `getCoverArt` thumbnail; the 120 dp det
|
|||||||
|
|
||||||
## Sync
|
## Sync
|
||||||
|
|
||||||
`SyncService.sync()` is serialized by `SyncManager`, which coalesces duplicate requests. It walks five entities in sequence — artists, albums, songs, playlists and playlist songs — and each one is a cold `Flow` from `MusicSource` that the service collects: it remembers the IDs already in the local mirror, upserts every batch as it arrives (one `immediateTransaction` per ~500-row batch), crosses those IDs off, and after that entity's stream ends deletes whatever is left, i.e. the rows the server no longer lists. The parser reads the HTTP response stream directly, so the fetched library is streamed page by page rather than materialised wholesale, though the prune still holds the existing ID set to diff against.
|
`SyncService.sync()` is serialized by `SyncManager`, which coalesces a request that arrives while one is already running into a single follow-up. The library and Home sync actions are no-ops while a sync runs (so repeated pull-to-refresh cannot pile up runs), while a programmatic request such as adding a server still gets its follow-up. It walks five entities in sequence — artists, albums, songs, playlists and playlist songs — and each one is a cold `Flow` from `MusicSource` that the service collects: it remembers the IDs already in the local mirror, upserts every batch as it arrives (one `immediateTransaction` per ~500-row batch), crosses those IDs off, and after that entity's stream ends deletes whatever is left, i.e. the rows the server no longer lists. The parser reads the HTTP response stream directly, so the fetched library is streamed page by page rather than materialised wholesale, though the prune still holds the existing ID set to diff against.
|
||||||
|
|
||||||
|
A run is triggered by the user (pull-to-refresh or a Sync action) or by `AutoSync`, a foreground-only timer. While the app has a visible Activity `AutoSync` re-checks at most every 15 minutes (the configured interval, capped) whether `syncIntervalMinutes` has elapsed since the last attempt and, if the mode allows (`Off`, `Wi-Fi only` or `Always`, default `Wi-Fi only`), the app is online and a server is configured, it asks `SyncManager` for a silent sync; nothing runs while the app is backgrounded, and a missed interval catches up on the next open. Only the time of the last attempt is persisted, so a server that keeps failing is retried once per interval rather than on every poll. A silent sync still shows the progress notification and the Settings spinner, but never a toast.
|
||||||
|
|
||||||
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
|
Songs have a primary and a fallback fetch. The primary probes `search3` with an empty query once (memoized per source); if the server supports it, the library is paged through `search3` 500 songs at a time. Otherwise the fallback pages `getAlbumList2` and then issues one `getAlbum` per album — it works everywhere but costs a request per album instead of per 500 songs. Every paged loop stops on a short page and is capped at 1000 pages. Playlists are fetched whole, and their songs are stored by position so a playlist that got shorter (or was emptied) is trimmed at the end.
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ org.gradle.parallel=true
|
|||||||
org.gradle.caching=true
|
org.gradle.caching=true
|
||||||
org.gradle.configuration-cache=true
|
org.gradle.configuration-cache=true
|
||||||
org.gradle.configuration-cache.problems=warn
|
org.gradle.configuration-cache.problems=warn
|
||||||
|
org.gradle.daemon.idletimeout=600000
|
||||||
|
|
||||||
android.useAndroidX=true
|
android.useAndroidX=true
|
||||||
android.nonTransitiveRClass=true
|
android.nonTransitiveRClass=true
|
||||||
|
|||||||
@@ -55,7 +55,9 @@ androidx-paging-compose = { group = "androidx.paging", name = "paging-compose",
|
|||||||
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
|
androidx-palette = { group = "androidx.palette", name = "palette-ktx", version.ref = "palette" }
|
||||||
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
|
reorderable = { module = "sh.calvin.reorderable:reorderable", version.ref = "reorderable" }
|
||||||
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
androidx-media3-exoplayer = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" }
|
||||||
|
androidx-media3-datasource = { group = "androidx.media3", name = "media3-datasource", version.ref = "media3" }
|
||||||
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
|
androidx-media3-datasource-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" }
|
||||||
|
androidx-media3-database = { group = "androidx.media3", name = "media3-database", version.ref = "media3" }
|
||||||
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
androidx-media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" }
|
||||||
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" }
|
||||||
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
coil-compose = { group = "io.coil-kt.coil3", name = "coil-compose", version.ref = "coil" }
|
||||||
|
|||||||
@@ -1 +0,0 @@
|
|||||||
539d294b0eb8bf9d7d09deb9819ff4af00c48259db496062629defc9289291bb
|
|
||||||
@@ -1,373 +0,0 @@
|
|||||||
Categories:
|
|
||||||
- Multimedia
|
|
||||||
License: GPL-3.0-only
|
|
||||||
AuthorName: austinried
|
|
||||||
AuthorEmail: subtracks@functionkey.xyz
|
|
||||||
WebSite: https://github.com/austinried/subtracks
|
|
||||||
SourceCode: https://github.com/austinried/subtracks
|
|
||||||
IssueTracker: https://github.com/austinried/subtracks/issues
|
|
||||||
Donate: https://ko-fi.com/austinried
|
|
||||||
|
|
||||||
AutoName: Subtracks
|
|
||||||
|
|
||||||
RepoType: git
|
|
||||||
Repo: https://github.com/austinried/subtracks
|
|
||||||
|
|
||||||
Builds:
|
|
||||||
- versionName: 1.0.1
|
|
||||||
versionCode: 3
|
|
||||||
commit: v1.0.1
|
|
||||||
subdir: android/app
|
|
||||||
sudo:
|
|
||||||
- sysctl fs.inotify.max_user_watches=524288
|
|
||||||
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
|
|
||||||
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
|
|
||||||
| sha256sum -c -
|
|
||||||
- tar xJf node.tar.xz
|
|
||||||
- cp -a node-v14.17.5-linux-x64/. /usr/local/
|
|
||||||
- npm -g install yarn
|
|
||||||
gradle:
|
|
||||||
- yes
|
|
||||||
srclibs:
|
|
||||||
- react-native-reanimated@2.2.0
|
|
||||||
prebuild:
|
|
||||||
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
|
|
||||||
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
|
|
||||||
- cd ../..
|
|
||||||
- yarn install
|
|
||||||
- cd $$react-native-reanimated$$
|
|
||||||
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
|
|
||||||
- yarn add react-native@0.64.1
|
|
||||||
scanignore:
|
|
||||||
- android/build.gradle
|
|
||||||
- node_modules/jsc-android
|
|
||||||
- node_modules/hermes-engine
|
|
||||||
- node_modules/react-native/android/com/facebook/react/react-native/*/
|
|
||||||
- node_modules/react-native-safe-area-context/android/build.gradle
|
|
||||||
- node_modules/react-native-screens/android/build.gradle
|
|
||||||
- node_modules/react-native-webview/android/build.gradle
|
|
||||||
- node_modules/react-native/template/android/build.gradle
|
|
||||||
- node_modules/react-native-image-colors/android/build.gradle
|
|
||||||
- node_modules/react-native-track-player/android/build.gradle
|
|
||||||
- node_modules/react-native-vector-icons/android/build.gradle
|
|
||||||
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/masked-view/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/netinfo/android/build.gradle
|
|
||||||
scandelete:
|
|
||||||
- node_modules
|
|
||||||
build:
|
|
||||||
- pushd $$react-native-reanimated$$
|
|
||||||
- FOR_HERMES=True gradle :assembleRelease
|
|
||||||
- popd
|
|
||||||
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
|
|
||||||
../../node_modules/react-native-reanimated/android/react-native-reanimated-64-hermes.aar
|
|
||||||
ndk: r21e
|
|
||||||
|
|
||||||
- versionName: 1.1.0
|
|
||||||
versionCode: 4
|
|
||||||
commit: v1.1.0
|
|
||||||
subdir: android/app
|
|
||||||
submodules: true
|
|
||||||
sudo:
|
|
||||||
- sysctl fs.inotify.max_user_watches=524288
|
|
||||||
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
|
|
||||||
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
|
|
||||||
| sha256sum -c -
|
|
||||||
- tar xJf node.tar.xz
|
|
||||||
- cp -a node-v14.17.5-linux-x64/. /usr/local/
|
|
||||||
- npm -g install yarn
|
|
||||||
gradle:
|
|
||||||
- yes
|
|
||||||
srclibs:
|
|
||||||
- react-native-reanimated@2.2.0
|
|
||||||
prebuild:
|
|
||||||
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
|
|
||||||
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
|
|
||||||
- cd ../..
|
|
||||||
- mkdir .yarncache
|
|
||||||
- yarn install --cache-folder ./.yarncache
|
|
||||||
- rm -rf .yarncache
|
|
||||||
- cd $$react-native-reanimated$$
|
|
||||||
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
|
|
||||||
- yarn add react-native@0.64.1
|
|
||||||
scanignore:
|
|
||||||
- android/build.gradle
|
|
||||||
- node_modules/jsc-android
|
|
||||||
- node_modules/hermes-engine
|
|
||||||
- node_modules/react-native/android/com/facebook/react/react-native/*/
|
|
||||||
- node_modules/react-native-safe-area-context/android/build.gradle
|
|
||||||
- node_modules/react-native-screens/android/build.gradle
|
|
||||||
- node_modules/react-native-webview/android/build.gradle
|
|
||||||
- node_modules/react-native/template/android/build.gradle
|
|
||||||
- node_modules/react-native-image-colors/android/build.gradle
|
|
||||||
- node_modules/react-native-track-player/android/build.gradle
|
|
||||||
- node_modules/react-native-vector-icons/android/build.gradle
|
|
||||||
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/masked-view/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/netinfo/android/build.gradle
|
|
||||||
scandelete:
|
|
||||||
- node_modules
|
|
||||||
- submodules
|
|
||||||
build:
|
|
||||||
- ../../scripts/prepare-exoplayer-ffmpeg.sh
|
|
||||||
- pushd $$react-native-reanimated$$
|
|
||||||
- FOR_HERMES=True gradle :assembleRelease
|
|
||||||
- popd
|
|
||||||
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
|
|
||||||
../../node_modules/react-native-reanimated/android/react-native-reanimated-64-hermes.aar
|
|
||||||
ndk: r21e
|
|
||||||
|
|
||||||
- versionName: 1.1.1
|
|
||||||
versionCode: 5
|
|
||||||
commit: 336e106e0152a479490a6c619fc0ee53461a2cd8
|
|
||||||
subdir: android/app
|
|
||||||
submodules: true
|
|
||||||
sudo:
|
|
||||||
- sysctl fs.inotify.max_user_watches=524288
|
|
||||||
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
|
|
||||||
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
|
|
||||||
| sha256sum -c -
|
|
||||||
- tar xJf node.tar.xz
|
|
||||||
- cp -a node-v14.17.5-linux-x64/. /usr/local/
|
|
||||||
- npm -g install yarn
|
|
||||||
gradle:
|
|
||||||
- yes
|
|
||||||
srclibs:
|
|
||||||
- react-native-reanimated@2.2.0
|
|
||||||
prebuild:
|
|
||||||
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
|
|
||||||
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
|
|
||||||
- cd ../..
|
|
||||||
- mkdir .yarncache
|
|
||||||
- yarn install --cache-folder ./.yarncache
|
|
||||||
- rm -rf .yarncache
|
|
||||||
- cd $$react-native-reanimated$$
|
|
||||||
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
|
|
||||||
- yarn add react-native@0.64.1
|
|
||||||
scanignore:
|
|
||||||
- android/build.gradle
|
|
||||||
- node_modules/jsc-android
|
|
||||||
- node_modules/hermes-engine
|
|
||||||
- node_modules/react-native/android/com/facebook/react/react-native/*/
|
|
||||||
- node_modules/react-native-safe-area-context/android/build.gradle
|
|
||||||
- node_modules/react-native-screens/android/build.gradle
|
|
||||||
- node_modules/react-native-webview/android/build.gradle
|
|
||||||
- node_modules/react-native/template/android/build.gradle
|
|
||||||
- node_modules/react-native-image-colors/android/build.gradle
|
|
||||||
- node_modules/react-native-track-player/android/build.gradle
|
|
||||||
- node_modules/react-native-vector-icons/android/build.gradle
|
|
||||||
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/masked-view/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/netinfo/android/build.gradle
|
|
||||||
scandelete:
|
|
||||||
- node_modules
|
|
||||||
- submodules
|
|
||||||
build:
|
|
||||||
- ../../scripts/prepare-exoplayer-ffmpeg.sh
|
|
||||||
- pushd $$react-native-reanimated$$
|
|
||||||
- FOR_HERMES=True gradle :assembleRelease
|
|
||||||
- popd
|
|
||||||
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
|
|
||||||
../../node_modules/react-native-reanimated/android/react-native-reanimated-64-hermes.aar
|
|
||||||
ndk: r21e
|
|
||||||
|
|
||||||
- versionName: 1.1.3
|
|
||||||
versionCode: 7
|
|
||||||
commit: 5a2ab98af55764c0ef4c73d2640a2d8c5c3b0609
|
|
||||||
subdir: android/app
|
|
||||||
submodules: true
|
|
||||||
sudo:
|
|
||||||
- sysctl fs.inotify.max_user_watches=524288
|
|
||||||
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
|
|
||||||
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
|
|
||||||
| sha256sum -c -
|
|
||||||
- tar xJf node.tar.xz
|
|
||||||
- cp -a node-v14.17.5-linux-x64/. /usr/local/
|
|
||||||
- npm -g install yarn
|
|
||||||
- apt-get update
|
|
||||||
- apt-get install -y wget
|
|
||||||
- apt-get install -y openjdk-11-jdk-headless
|
|
||||||
- update-alternatives --auto java
|
|
||||||
gradle:
|
|
||||||
- yes
|
|
||||||
srclibs:
|
|
||||||
- react-native-reanimated@2.3.1
|
|
||||||
prebuild:
|
|
||||||
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
|
|
||||||
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
|
|
||||||
- cd ../..
|
|
||||||
- mkdir .yarncache
|
|
||||||
- yarn install --network-concurrency 1 --cache-folder ./.yarncache
|
|
||||||
- rm -rf .yarncache
|
|
||||||
- cd $$react-native-reanimated$$
|
|
||||||
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
|
|
||||||
- yarn add react-native@0.67.1
|
|
||||||
scanignore:
|
|
||||||
- android/build.gradle
|
|
||||||
- node_modules/jsc-android
|
|
||||||
- node_modules/hermes-engine
|
|
||||||
- node_modules/react-native/android/com/facebook/react/react-native/*/
|
|
||||||
- node_modules/react-native-safe-area-context/android/build.gradle
|
|
||||||
- node_modules/react-native-screens/android/build.gradle
|
|
||||||
- node_modules/react-native-webview/android/build.gradle
|
|
||||||
- node_modules/react-native/template/android/build.gradle
|
|
||||||
- node_modules/react-native-image-colors/android/build.gradle
|
|
||||||
- node_modules/react-native-get-random-values/android/build.gradle
|
|
||||||
- node_modules/react-native-track-player/android/build.gradle
|
|
||||||
- node_modules/react-native-vector-icons/android/build.gradle
|
|
||||||
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/masked-view/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/netinfo/android/build.gradle
|
|
||||||
scandelete:
|
|
||||||
- node_modules
|
|
||||||
- submodules
|
|
||||||
build:
|
|
||||||
- ../../scripts/prepare-exoplayer-ffmpeg.sh
|
|
||||||
- pushd $$react-native-reanimated$$
|
|
||||||
- FOR_HERMES=True gradle :assembleRelease
|
|
||||||
- popd
|
|
||||||
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
|
|
||||||
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-hermes.aar
|
|
||||||
- pushd $$react-native-reanimated$$
|
|
||||||
- gradle :assembleRelease
|
|
||||||
- popd
|
|
||||||
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
|
|
||||||
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-jsc.aar
|
|
||||||
ndk: r21e
|
|
||||||
|
|
||||||
- versionName: 1.2.0
|
|
||||||
versionCode: 8
|
|
||||||
commit: 4905f75564bf8f6b96597a91d3d8f39b1551a56e
|
|
||||||
subdir: android/app
|
|
||||||
submodules: true
|
|
||||||
sudo:
|
|
||||||
- sysctl fs.inotify.max_user_watches=524288
|
|
||||||
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
|
|
||||||
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
|
|
||||||
| sha256sum -c -
|
|
||||||
- tar xJf node.tar.xz --strip-components=1 -C /usr/local/
|
|
||||||
- npm -g install yarn
|
|
||||||
- apt-get update
|
|
||||||
- apt-get install -y openjdk-11-jdk-headless
|
|
||||||
- update-alternatives --auto java
|
|
||||||
gradle:
|
|
||||||
- yes
|
|
||||||
srclibs:
|
|
||||||
- react-native-reanimated@2.3.1
|
|
||||||
prebuild:
|
|
||||||
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
|
|
||||||
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
|
|
||||||
- cd ../..
|
|
||||||
- mkdir .yarncache
|
|
||||||
- yarn install --network-concurrency 1 --cache-folder ./.yarncache
|
|
||||||
- rm -rf .yarncache
|
|
||||||
- cd $$react-native-reanimated$$
|
|
||||||
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
|
|
||||||
- yarn add react-native@0.67.4
|
|
||||||
scanignore:
|
|
||||||
- android/build.gradle
|
|
||||||
- node_modules/jsc-android
|
|
||||||
- node_modules/hermes-engine
|
|
||||||
- node_modules/react-native/android/com/facebook/react/react-native/*/
|
|
||||||
- node_modules/react-native-safe-area-context/android/build.gradle
|
|
||||||
- node_modules/react-native-screens/android/build.gradle
|
|
||||||
- node_modules/react-native-webview/android/build.gradle
|
|
||||||
- node_modules/react-native/template/android/build.gradle
|
|
||||||
- node_modules/react-native-image-colors/android/build.gradle
|
|
||||||
- node_modules/react-native-blob-util/android/build.gradle
|
|
||||||
- node_modules/react-native-track-player/android/build.gradle
|
|
||||||
- node_modules/react-native-vector-icons/android/build.gradle
|
|
||||||
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/masked-view/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/netinfo/android/build.gradle
|
|
||||||
scandelete:
|
|
||||||
- node_modules
|
|
||||||
- submodules
|
|
||||||
build:
|
|
||||||
- ../../scripts/prepare-exoplayer-ffmpeg.sh
|
|
||||||
- pushd $$react-native-reanimated$$
|
|
||||||
- FOR_HERMES=True gradle :assembleRelease
|
|
||||||
- popd
|
|
||||||
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
|
|
||||||
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-hermes.aar
|
|
||||||
- pushd $$react-native-reanimated$$
|
|
||||||
- gradle :assembleRelease
|
|
||||||
- popd
|
|
||||||
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
|
|
||||||
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-jsc.aar
|
|
||||||
ndk: r21e
|
|
||||||
|
|
||||||
- versionName: 1.3.0
|
|
||||||
versionCode: 9
|
|
||||||
commit: 2bf3e8853de705bb49aa4175b1ad5a9b5bb431b2
|
|
||||||
subdir: android/app
|
|
||||||
submodules: true
|
|
||||||
sudo:
|
|
||||||
- sysctl fs.inotify.max_user_watches=524288
|
|
||||||
- curl -Lo node.tar.xz https://nodejs.org/dist/v14.17.5/node-v14.17.5-linux-x64.tar.xz
|
|
||||||
- echo "2d759de07a50cd7f75bd73d67e97b0d0e095ee3c413efac7d1b3d1e84ed76fff node.tar.xz"
|
|
||||||
| sha256sum -c -
|
|
||||||
- tar xJf node.tar.xz --strip-components=1 -C /usr/local/
|
|
||||||
- npm -g install yarn
|
|
||||||
- apt-get update
|
|
||||||
- apt-get install -y openjdk-11-jdk-headless
|
|
||||||
- update-alternatives --auto java
|
|
||||||
gradle:
|
|
||||||
- yes
|
|
||||||
srclibs:
|
|
||||||
- react-native-reanimated@2.3.1
|
|
||||||
prebuild:
|
|
||||||
- echo -e '\norg.gradle.jvmargs=-Xmx4g -XX:MaxPermSize=4g' >> ../gradle.properties
|
|
||||||
- echo -e "android { packagingOptions { pickFirst 'lib/**/*.so' } }" >> build.gradle
|
|
||||||
- cd ../..
|
|
||||||
- mkdir .yarncache
|
|
||||||
- yarn install --network-concurrency 1 --cache-folder ./.yarncache
|
|
||||||
- rm -rf .yarncache
|
|
||||||
- cd $$react-native-reanimated$$
|
|
||||||
- echo -e "android { ndkVersion '21.4.7075529' }" >> build.gradle
|
|
||||||
- yarn add react-native@0.67.4
|
|
||||||
scanignore:
|
|
||||||
- android/build.gradle
|
|
||||||
- node_modules/jsc-android
|
|
||||||
- node_modules/hermes-engine
|
|
||||||
- node_modules/react-native/android/com/facebook/react/react-native/*/
|
|
||||||
- node_modules/react-native-safe-area-context/android/build.gradle
|
|
||||||
- node_modules/react-native-screens/android/build.gradle
|
|
||||||
- node_modules/react-native-webview/android/build.gradle
|
|
||||||
- node_modules/react-native/template/android/build.gradle
|
|
||||||
- node_modules/react-native-image-colors/android/build.gradle
|
|
||||||
- node_modules/react-native-blob-util/android/build.gradle
|
|
||||||
- node_modules/react-native-localize/android/build.gradle
|
|
||||||
- node_modules/react-native-track-player/android/build.gradle
|
|
||||||
- node_modules/react-native-vector-icons/android/build.gradle
|
|
||||||
- node_modules/@react-native-async-storage/async-storage/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/masked-view/android/build.gradle
|
|
||||||
- node_modules/@react-native-community/netinfo/android/build.gradle
|
|
||||||
scandelete:
|
|
||||||
- node_modules
|
|
||||||
- submodules
|
|
||||||
build:
|
|
||||||
- ../../scripts/prepare-exoplayer-ffmpeg.sh
|
|
||||||
- pushd $$react-native-reanimated$$
|
|
||||||
- FOR_HERMES=True gradle :assembleRelease
|
|
||||||
- popd
|
|
||||||
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
|
|
||||||
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-hermes.aar
|
|
||||||
- pushd $$react-native-reanimated$$
|
|
||||||
- gradle :assembleRelease
|
|
||||||
- popd
|
|
||||||
- mv $$react-native-reanimated$$/build/outputs/aar/android-release.aar
|
|
||||||
../../node_modules/react-native-reanimated/android/react-native-reanimated-67-jsc.aar
|
|
||||||
ndk: r21e
|
|
||||||
|
|
||||||
- versionName: 3.0.0
|
|
||||||
versionCode: 13
|
|
||||||
commit: v3.0.0
|
|
||||||
subdir: app
|
|
||||||
gradle:
|
|
||||||
- yes
|
|
||||||
|
|
||||||
AutoUpdateMode: Version
|
|
||||||
UpdateCheckMode: Tags
|
|
||||||
CurrentVersion: 3.0.0
|
|
||||||
CurrentVersionCode: 13
|
|
||||||