From 62ce9398717ce4747cabd4b9f3fb7a6a0d6a8f79 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 6 Nov 2026 21:14:10 +0000 Subject: [PATCH] Update what's kept on the phone in the background A periodic WorkManager job, SyncWorker, syncs the rules for the last Clementine that synced, at its last address, about every 6 hours. It runs only when Android sees fit for work that can wait: charging, idle, battery and storage not low, and on a network Download over allows. It opens a connection of its own, checks that the Clementine answering is the one the rules are for, syncs through Syncer, and disconnects. When the app is already connected, its connection syncs instead. When Android stops the job, SyncEngine downloads nothing more and keeps what it has, so the next run carries on. A setting under Download over, on by default, turns it off. App sets WorkManager up on first use, as its Configuration.Provider, so it's there in Robolectric tests too. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01R2bAq6kZW6eyymkA3zsd4p --- app/src/main/AndroidManifest.xml | 10 + .../java/de/qspool/clementineremote/App.java | 18 +- .../SharedPreferencesKeys.java | 3 + .../backend/sync/BackgroundConnection.kt | 95 +++++++++ .../backend/sync/BackgroundSync.kt | 107 ++++++++++ .../backend/sync/SyncEngine.kt | 7 +- .../backend/sync/SyncWorker.kt | 54 +++++ .../clementineremote/backend/sync/Syncer.kt | 41 ++-- .../ui/settings/SettingsScreen.kt | 7 + app/src/main/res/values/strings.xml | 6 + .../backend/sync/BackgroundSyncTest.kt | 184 ++++++++++++++++++ .../backend/sync/SyncEngineTest.kt | 24 +++ 12 files changed, 543 insertions(+), 13 deletions(-) create mode 100644 app/src/main/java/de/qspool/clementineremote/backend/sync/BackgroundConnection.kt create mode 100644 app/src/main/java/de/qspool/clementineremote/backend/sync/BackgroundSync.kt create mode 100644 app/src/main/java/de/qspool/clementineremote/backend/sync/SyncWorker.kt create mode 100644 app/src/test/java/de/qspool/clementineremote/backend/sync/BackgroundSyncTest.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ca4e13ca..1308cc2f 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -126,6 +126,16 @@ + + + + + diff --git a/app/src/main/java/de/qspool/clementineremote/App.java b/app/src/main/java/de/qspool/clementineremote/App.java index db150347..7aa1ec8d 100644 --- a/app/src/main/java/de/qspool/clementineremote/App.java +++ b/app/src/main/java/de/qspool/clementineremote/App.java @@ -24,13 +24,21 @@ import android.os.Build; import android.os.StrictMode; +import androidx.annotation.NonNull; +import androidx.work.Configuration; + import de.qspool.clementineremote.backend.Clementine; import de.qspool.clementineremote.backend.ClementinePlayerConnection; import de.qspool.clementineremote.backend.downloader.DownloadManager; +import de.qspool.clementineremote.backend.sync.BackgroundSync; import de.qspool.clementineremote.widget.ClementineWidget; import de.qspool.clementineremote.utils.StrictModePolicies; -public class App extends Application { +/** + * The app. It sets WorkManager up when it's first used, rather than at startup, so that it's + * there wherever the app runs, as in tests. + */ +public class App extends Application implements Configuration.Provider { public static ClementinePlayerConnection ClementineConnection = null; @@ -62,6 +70,14 @@ public void onCreate() { DownloadManager.getInstance(this); ClementineWidget.publishPreview(this); + + BackgroundSync.start(this); + } + + @NonNull + @Override + public Configuration getWorkManagerConfiguration() { + return new Configuration.Builder().build(); } private void createNotificationChannel() { diff --git a/app/src/main/java/de/qspool/clementineremote/SharedPreferencesKeys.java b/app/src/main/java/de/qspool/clementineremote/SharedPreferencesKeys.java index f9080d3e..c59d7fad 100644 --- a/app/src/main/java/de/qspool/clementineremote/SharedPreferencesKeys.java +++ b/app/src/main/java/de/qspool/clementineremote/SharedPreferencesKeys.java @@ -92,4 +92,7 @@ public class SharedPreferencesKeys { /** The server_id of the Clementine last connected that syncs, whose rules show offline. */ public final static String SP_SYNC_SERVER_ID = "sync_server_id"; + + /** Whether what's kept on the phone is updated in the background (BackgroundSync). */ + public final static String SP_BACKGROUND_SYNC = "pref_background_sync"; } diff --git a/app/src/main/java/de/qspool/clementineremote/backend/sync/BackgroundConnection.kt b/app/src/main/java/de/qspool/clementineremote/backend/sync/BackgroundConnection.kt new file mode 100644 index 00000000..f8bf0513 --- /dev/null +++ b/app/src/main/java/de/qspool/clementineremote/backend/sync/BackgroundConnection.kt @@ -0,0 +1,95 @@ +package de.qspool.clementineremote.backend.sync + +import de.qspool.clementineremote.backend.ClementineSimpleConnection +import de.qspool.clementineremote.backend.pb.ClementineMessage +import de.qspool.clementineremote.backend.pb.ClementineMessage.ErrorMessage +import de.qspool.clementineremote.backend.pb.ClementineMessageFactory +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.MsgType +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseClementineInfo +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseSyncManifest +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.channels.BufferOverflow +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeoutOrNull +import java.io.Closeable +import kotlin.concurrent.thread + +/** + * A connection of a background sync's own to a Clementine, while the app isn't connected: it + * asks for manifests over it and nothing more, and leaves the app's connection, its player and + * its notifications alone. [info] is what Clementine said it is when it connected. + */ +internal class BackgroundConnection private constructor( + private val connection: Connection, + val info: ResponseClementineInfo, + private val manifests: MutableSharedFlow, +) : Closeable { + + /** For [SyncEngine]: songs come from Clementine's HTTP server, on the same address. */ + fun syncConnection(): SyncConnection = ClementineSyncConnection( + info.serverId, { connection.sendRequest(it) }, manifests, connection::getServerAddress, + ) + + override fun close() { + connection.disconnect(ClementineMessage.getMessage(MsgType.DISCONNECT)) + connection.close() + } + + /** A connection that can be closed from here, even when saying goodbye fails. */ + private class Connection : ClementineSimpleConnection() { + @Synchronized + fun close() { + // Without streams, it never connected. + if (mOut != null && !mSocket.isClosed) closeSocket() + } + } + + companion object { + /** How long a read waits before checking the connection is still open. */ + private const val READ_TIMEOUT_MS = 3000 + + /** + * Connects to Clementine at [host] and [port] with [authCode], and waits up to + * [timeoutMs] for it to say what it is. Null when it isn't there, refuses the code or + * doesn't answer. + */ + fun open(host: String, port: Int, authCode: Int, timeoutMs: Long = 15_000): BackgroundConnection? { + val connection = Connection() + // Not a downloader: Clementine tells only other remotes what it is. + val connect = ClementineMessageFactory.buildConnectMessage(host, port, authCode, false, false) + if (!connection.createConnection(connect)) { + connection.close() + return null + } + val manifests = MutableSharedFlow( + extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + val info = CompletableDeferred() + thread(name = "BackgroundConnection", isDaemon = true) { + while (connection.isConnected) { + val message = connection.getProtoc(READ_TIMEOUT_MS) + if (message.isErrorMessage) { + if (message.errorMessage == ErrorMessage.TIMEOUT) continue + break + } + when (message.messageType) { + MsgType.INFO -> info.complete(message.message.responseClementineInfo) + MsgType.SYNC_MANIFEST -> manifests.tryEmit(message.message.responseSyncManifest) + // Such as for a wrong code. + MsgType.DISCONNECT -> break + else -> {} + } + } + connection.close() + info.complete(null) + } + val answer = runBlocking { withTimeoutOrNull(timeoutMs) { info.await() } } + if (answer == null) { + connection.close() + return null + } + return BackgroundConnection(connection, answer, manifests) + } + } +} diff --git a/app/src/main/java/de/qspool/clementineremote/backend/sync/BackgroundSync.kt b/app/src/main/java/de/qspool/clementineremote/backend/sync/BackgroundSync.kt new file mode 100644 index 00000000..6ef62b15 --- /dev/null +++ b/app/src/main/java/de/qspool/clementineremote/backend/sync/BackgroundSync.kt @@ -0,0 +1,107 @@ +package de.qspool.clementineremote.backend.sync + +import android.content.Context +import android.content.SharedPreferences +import androidx.work.Constraints +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.NetworkType +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager +import de.qspool.clementineremote.App +import de.qspool.clementineremote.BuildConfig +import de.qspool.clementineremote.SharedPreferencesKeys +import de.qspool.clementineremote.backend.Clementine +import de.qspool.clementineremote.backend.downloader.DownloadNetwork +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseClementineInfo +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ServerFeature +import java.util.concurrent.TimeUnit + +/** + * Updates what's kept on the phone in the background, with Update kept music in the background + * on: [SyncWorker] runs every 6 hours or so, when Android sees fit for work that can wait. That's + * while the phone is charging, idle, with battery and storage to spare, and on a network Download + * over allows, so mostly overnight on home Wi-Fi. It syncs the rules for the last Clementine that + * synced, at its last address. + */ +object BackgroundSync { + + private const val WORK = "background-sync" + private const val INTERVAL_HOURS = 6L + + /** The settings that change what's scheduled. */ + private val SETTINGS = setOf( + SharedPreferencesKeys.SP_BACKGROUND_SYNC, SharedPreferencesKeys.SP_DOWNLOAD_NETWORK, + SharedPreferencesKeys.SP_WIFI_ONLY, + ) + + // Held here: SharedPreferences keeps only a weak reference to its listeners. + private val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key in SETTINGS) schedule(App.getApp()) + } + + /** The last Clementine that synced, where it was. */ + data class LastClementine(val host: String, val port: Int, val authCode: Int, val serverId: String) + + /** Whether background sync is on, in [preferences]. */ + @JvmStatic + fun enabled(preferences: SharedPreferences): Boolean = + BuildConfig.TESTING_FEATURES && preferences.getBoolean(SharedPreferencesKeys.SP_BACKGROUND_SYNC, true) + + /** Schedules background sync, and again whenever its settings change. Called once, at startup. */ + @JvmStatic + fun start(context: Context) { + if (!BuildConfig.TESTING_FEATURES) return + schedule(context) + App.getPreferences().registerOnSharedPreferenceChangeListener(listener) + } + + private fun schedule(context: Context) { + val preferences = App.getPreferences() + val work = WorkManager.getInstance(context) + if (!enabled(preferences)) { + work.cancelUniqueWork(WORK) + return + } + work.enqueueUniquePeriodicWork( + WORK, ExistingPeriodicWorkPolicy.UPDATE, + PeriodicWorkRequestBuilder(INTERVAL_HOURS, TimeUnit.HOURS) + .setConstraints(constraints(DownloadNetwork.chosen(preferences))) + .build(), + ) + } + + /** When Android may run a background sync, given the networks Download over allows. */ + fun constraints(network: DownloadNetwork): Constraints = Constraints.Builder() + .setRequiredNetworkType( + when (network) { + DownloadNetwork.WIFI_ONLY -> NetworkType.UNMETERED + DownloadNetwork.MOBILE_DATA -> NetworkType.NOT_ROAMING + DownloadNetwork.ANY -> NetworkType.CONNECTED + } + ) + .setRequiresCharging(true) + .setRequiresBatteryNotLow(true) + .setRequiresStorageNotLow(true) + .setRequiresDeviceIdle(true) + .build() + + /** The last Clementine that synced, from [preferences], or null if none has. */ + fun lastClementine(preferences: SharedPreferences): LastClementine? { + val host = preferences.getString(SharedPreferencesKeys.SP_KEY_IP, null)?.takeIf { it.isNotBlank() } ?: return null + val serverId = preferences.getString(SharedPreferencesKeys.SP_SYNC_SERVER_ID, null)?.takeIf { it.isNotEmpty() } + ?: return null + val port = preferences.getString(SharedPreferencesKeys.SP_KEY_PORT, null)?.toIntOrNull() ?: Clementine.DefaultPort + return LastClementine(host, port, preferences.getInt(SharedPreferencesKeys.SP_LAST_AUTH_CODE, 0), serverId) + } + + /** Whether any of [rules] would sync: those updated each time, and those yet to get their songs. */ + fun hasRulesToSync(rules: List): Boolean = + rules.any { it.refreshPolicy != RefreshPolicy.NEVER || it.lastSyncedAt == 0L } + + /** + * Whether the Clementine that answered, saying [info], is the one the rules are for + * ([serverId]) and syncs. Another one at the same address is left alone. + */ + fun isTheOneToSync(info: ResponseClementineInfo, serverId: String): Boolean = + info.serverId == serverId && ServerFeature.SERVER_FEATURE_SYNC in info.featuresList +} diff --git a/app/src/main/java/de/qspool/clementineremote/backend/sync/SyncEngine.kt b/app/src/main/java/de/qspool/clementineremote/backend/sync/SyncEngine.kt index 8683647d..f3c3ee73 100644 --- a/app/src/main/java/de/qspool/clementineremote/backend/sync/SyncEngine.kt +++ b/app/src/main/java/de/qspool/clementineremote/backend/sync/SyncEngine.kt @@ -58,7 +58,9 @@ sealed interface RuleResult { * * [supportedFormats] are what the phone plays, as for streaming, and [delete] removes a song * [storage] saved, by its URI. [onProgress] hears how many of a rule's songs to download have - * been, before the first and after each one. + * been, before the first and after each one. Once [stopped] says so, as when Android ends a + * background sync, it downloads nothing more: the rule being synced fails, the rules after it + * aren't tried, and every song downloaded so far stays. */ @WorkerThread class SyncEngine( @@ -68,12 +70,14 @@ class SyncEngine( private val supportedFormats: List, private val now: () -> Long = System::currentTimeMillis, private val onProgress: (ruleId: Long, done: Int, total: Int) -> Unit = { _, _, _ -> }, + private val stopped: () -> Boolean = { false }, ) { fun sync(connection: SyncConnection): SyncResult { val serverId = connection.serverId val results = LinkedHashMap() for (rule in store.rules(serverId)) { + if (stopped()) break // A rule kept as it is has its songs once it has synced. The other policies refresh // on every sync for now; intervals and listening come later. if (rule.refreshPolicy == RefreshPolicy.NEVER && rule.lastSyncedAt > 0) continue @@ -111,6 +115,7 @@ class SyncEngine( try { for ((item, have, changed) in wanted) { if (changed) { + if (stopped()) throw IOException("The sync was stopped") download(connection, rule.serverId, item, have) downloaded++ onProgress(rule.id, downloaded, total) diff --git a/app/src/main/java/de/qspool/clementineremote/backend/sync/SyncWorker.kt b/app/src/main/java/de/qspool/clementineremote/backend/sync/SyncWorker.kt new file mode 100644 index 00000000..04ac26f5 --- /dev/null +++ b/app/src/main/java/de/qspool/clementineremote/backend/sync/SyncWorker.kt @@ -0,0 +1,54 @@ +package de.qspool.clementineremote.backend.sync + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import de.qspool.clementineremote.App +import de.qspool.clementineremote.backend.downloader.DownloadNetwork +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +/** + * A background sync ([BackgroundSync]): connects to the last Clementine that synced, syncs its + * rules and disconnects. It always succeeds, so WorkManager waits for the next time rather than + * retrying: Clementine not being on is the usual case, not a failure. When Android stops it, + * after 10 minutes at most, what has downloaded stays, and the next run carries on. + */ +class SyncWorker(context: Context, parameters: WorkerParameters) : CoroutineWorker(context, parameters) { + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + sync() + Result.success() + } + + private suspend fun sync() { + val preferences = App.getPreferences() + if (!BackgroundSync.enabled(preferences)) return + // The app is connected, and its connection syncs. + if (App.ClementineConnection?.isConnected == true) { + Syncer.syncNow() + return + } + val last = BackgroundSync.lastClementine(preferences) ?: return + if (!BackgroundSync.hasRulesToSync(Syncer.store(applicationContext).rules(last.serverId))) return + if (!DownloadNetwork.chosen(preferences).allowsCurrentNetwork(applicationContext)) return + + val connection = BackgroundConnection.open(last.host, last.port, last.authCode) + if (connection == null) { + Log.i(TAG, "Clementine isn't reachable at ${last.host}:${last.port}") + return + } + connection.use { + if (!BackgroundSync.isTheOneToSync(it.info, last.serverId)) { + Log.i(TAG, "Another Clementine, or one that doesn't sync, is at ${last.host}") + return + } + Syncer.syncInBackground(it.syncConnection(), stopped = { isStopped }) + } + } + + private companion object { + const val TAG = "SyncWorker" + } +} diff --git a/app/src/main/java/de/qspool/clementineremote/backend/sync/Syncer.kt b/app/src/main/java/de/qspool/clementineremote/backend/sync/Syncer.kt index 788fb1ad..82c2444c 100644 --- a/app/src/main/java/de/qspool/clementineremote/backend/sync/Syncer.kt +++ b/app/src/main/java/de/qspool/clementineremote/backend/sync/Syncer.kt @@ -41,7 +41,8 @@ data class SyncProgress(val done: Int, val total: Int) /** * Syncs the rules for the connected Clementine while the app is connected to it: once when it * connects, and when asked ([syncNow]), such as after a rule is made ([keep]) or removed - * ([remove]), over the networks Download over allows. Background sync comes later. + * ([remove]), over the networks Download over allows. [SyncWorker] syncs in the background, over a + * connection of its own ([syncInBackground]). */ object Syncer { @@ -176,14 +177,36 @@ object Syncer { job = scope.launch { do { synchronized(this@Syncer) { again = false } - runOnce(conn) + val syncing = RemoteRepository.syncing.value + if (syncing.available) { + runOnce( + ClementineSyncConnection( + syncing.serverId, RemoteRepository::send, RemoteRepository.syncManifests, + conn::getServerAddress, + ) + ) + } } while (synchronized(this@Syncer) { again }) } } - private fun runOnce(connection: ClementinePlayerConnection) { - val syncing = RemoteRepository.syncing.value - if (!syncing.available) return + /** + * Syncs over [connection], a background sync's own connection, unless a sync is running + * already, and returns whether it did. Once [stopped] says so, it downloads nothing more. A + * sync asked for meanwhile, such as by the app connecting, runs after it. + */ + suspend fun syncInBackground(connection: SyncConnection, stopped: () -> Boolean): Boolean { + val started = synchronized(this) { + if (job?.isActive == true) return false + again = false + scope.launch { runOnce(connection, stopped) }.also { job = it } + } + started.join() + if (synchronized(this) { again }) syncNow() + return true + } + + private fun runOnce(connection: SyncConnection, stopped: () -> Boolean = { false }) { if (!DownloadNetwork.chosen(App.getPreferences()).allowsCurrentNetwork(context)) { _status.value = _status.value.copy(networkNotAllowed = true) return @@ -206,14 +229,10 @@ object Syncer { SyncNotification.show(context, it.rule.title, done, total) } }, + stopped = stopped, ) try { - val result = engine.sync( - ClementineSyncConnection( - syncing.serverId, RemoteRepository::send, RemoteRepository.syncManifests, - connection::getServerAddress, - ) - ) + val result = engine.sync(connection) _status.value = _status.value.copy(running = false, results = result.rules) } catch (e: RuntimeException) { // The store or storage failing: try again next time rather than stop the app. diff --git a/app/src/main/java/de/qspool/clementineremote/ui/settings/SettingsScreen.kt b/app/src/main/java/de/qspool/clementineremote/ui/settings/SettingsScreen.kt index ca16070b..fa300396 100644 --- a/app/src/main/java/de/qspool/clementineremote/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/de/qspool/clementineremote/ui/settings/SettingsScreen.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.res.stringArrayResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardType import de.qspool.clementineremote.App +import de.qspool.clementineremote.BuildConfig import de.qspool.clementineremote.R import de.qspool.clementineremote.SharedPreferencesKeys import de.qspool.clementineremote.backend.Clementine @@ -147,6 +148,12 @@ private fun DownloadSettings(store: PreferenceStore, downloadVolumes: List= Build.VERSION_CODES.Q) { ActionSetting( diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b6403abb..9c68a2eb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -305,6 +305,12 @@ Wi-Fi and mobile data, not when roaming Any network + + Update kept music in the background + + While the phone is charging, on a network Download over allows, music set to Every time I connect is updated from the last Clementine you connected to, if it\'s on. Set download folder Save downloads to Not available. Choose where to save downloads. diff --git a/app/src/test/java/de/qspool/clementineremote/backend/sync/BackgroundSyncTest.kt b/app/src/test/java/de/qspool/clementineremote/backend/sync/BackgroundSyncTest.kt new file mode 100644 index 00000000..06422248 --- /dev/null +++ b/app/src/test/java/de/qspool/clementineremote/backend/sync/BackgroundSyncTest.kt @@ -0,0 +1,184 @@ +package de.qspool.clementineremote.backend.sync + +import android.content.Context +import androidx.core.content.edit +import androidx.work.NetworkType +import de.qspool.clementineremote.SharedPreferencesKeys +import de.qspool.clementineremote.backend.downloader.DownloadNetwork +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.Message +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.MsgType +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ReasonDisconnect +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.RequestSyncManifest +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseClementineInfo +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseDisconnect +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ResponseSyncManifest +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.ServerFeature +import de.qspool.clementineremote.backend.pb.ClementineRemoteProtocolBuffer.SyncSourceKind +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.RuntimeEnvironment +import java.io.DataInputStream +import java.io.DataOutputStream +import java.io.IOException +import java.net.InetAddress +import java.net.ServerSocket +import kotlin.concurrent.thread + +/** When a background sync may run, which Clementine it syncs with, and its own connection to it. */ +@RunWith(RobolectricTestRunner::class) +class BackgroundSyncTest { + + private val preferences = RuntimeEnvironment.getApplication() + .getSharedPreferences("background-sync-test", Context.MODE_PRIVATE) + private var server: ServerSocket? = null + + @After + fun tearDown() { + server?.close() + } + + @Test + fun runsOnlyWhenThePhoneIsFitForWorkThatCanWait() { + val wifi = BackgroundSync.constraints(DownloadNetwork.WIFI_ONLY) + assertEquals(NetworkType.UNMETERED, wifi.requiredNetworkType) + assertTrue(wifi.requiresCharging()) + assertTrue(wifi.requiresBatteryNotLow()) + assertTrue(wifi.requiresStorageNotLow()) + assertTrue(wifi.requiresDeviceIdle()) + + // The networks follow Download over. + assertEquals(NetworkType.NOT_ROAMING, BackgroundSync.constraints(DownloadNetwork.MOBILE_DATA).requiredNetworkType) + assertEquals(NetworkType.CONNECTED, BackgroundSync.constraints(DownloadNetwork.ANY).requiredNetworkType) + } + + @Test + fun syncsWithTheLastClementineThatSynced() { + assertNull(BackgroundSync.lastClementine(preferences)) + + preferences.edit { + putString(SharedPreferencesKeys.SP_KEY_IP, "192.168.1.20") + putInt(SharedPreferencesKeys.SP_LAST_AUTH_CODE, 12345) + } + // Connected, but never to a Clementine that syncs. + assertNull(BackgroundSync.lastClementine(preferences)) + + preferences.edit { putString(SharedPreferencesKeys.SP_SYNC_SERVER_ID, SERVER) } + assertEquals( + BackgroundSync.LastClementine("192.168.1.20", 5500, 12345, SERVER), + BackgroundSync.lastClementine(preferences), + ) + preferences.edit { putString(SharedPreferencesKeys.SP_KEY_PORT, "5600") } + assertEquals(5600, BackgroundSync.lastClementine(preferences)!!.port) + } + + @Test + fun syncsOnlyRulesThatWouldSync() { + fun rule(policy: RefreshPolicy, lastSyncedAt: Long) = SyncRule( + serverId = SERVER, sourceId = "playlist/1", title = "Mix", kind = SyncSourceKind.SYNC_SOURCE_KIND_PLAYLIST, + refreshPolicy = policy, lastSyncedAt = lastSyncedAt, + ) + assertFalse(BackgroundSync.hasRulesToSync(emptyList())) + // Kept as it is, with its songs. + assertFalse(BackgroundSync.hasRulesToSync(listOf(rule(RefreshPolicy.NEVER, 1)))) + // Kept as it is, still to get its songs. + assertTrue(BackgroundSync.hasRulesToSync(listOf(rule(RefreshPolicy.NEVER, 0)))) + assertTrue(BackgroundSync.hasRulesToSync(listOf(rule(RefreshPolicy.ON_CONNECT, 1)))) + } + + @Test + fun syncsOnlyWithTheClementineTheRulesAreFor() { + fun info(serverId: String, sync: Boolean) = ResponseClementineInfo.newBuilder() + .setServerId(serverId) + .apply { if (sync) addFeatures(ServerFeature.SERVER_FEATURE_SYNC) } + .build() + assertTrue(BackgroundSync.isTheOneToSync(info(SERVER, sync = true), SERVER)) + assertFalse(BackgroundSync.isTheOneToSync(info("another", sync = true), SERVER)) + // Started without media sync. + assertFalse(BackgroundSync.isTheOneToSync(info(SERVER, sync = false), SERVER)) + } + + /** A Clementine on [server] that answers a connect with [answer], and a manifest request with an empty manifest. */ + private fun clementine(answer: Message.Builder) { + val socket = ServerSocket(0, 1, InetAddress.getLoopbackAddress()) + server = socket + thread(isDaemon = true) { + try { + serve(socket, answer) + } catch (e: IOException) { + // The app hung up. + } + } + } + + private fun serve(socket: ServerSocket, answer: Message.Builder) { + socket.accept().use { client -> + val input = DataInputStream(client.getInputStream()) + val output = DataOutputStream(client.getOutputStream()) + fun send(message: Message.Builder) { + val bytes = message.setVersion(Message.getDefaultInstance().version).build().toByteArray() + output.writeInt(bytes.size) + output.write(bytes) + output.flush() + } + while (true) { + val bytes = ByteArray(input.readInt()) + input.readFully(bytes) + val message = Message.parseFrom(bytes) + when (message.type) { + MsgType.CONNECT -> send(answer) + MsgType.REQUEST_SYNC_MANIFEST -> send( + Message.newBuilder().setType(MsgType.SYNC_MANIFEST).setResponseSyncManifest( + ResponseSyncManifest.newBuilder().setSourceId(message.requestSyncManifest.sourceId) + ) + ) + MsgType.DISCONNECT -> return + else -> {} + } + } + } + } + + @Test + fun connectsOnItsOwnAndAsksForManifests() { + clementine( + Message.newBuilder().setType(MsgType.INFO).setResponseClementineInfo( + ResponseClementineInfo.newBuilder().setServerId(SERVER).addFeatures(ServerFeature.SERVER_FEATURE_SYNC) + ) + ) + + val connection = BackgroundConnection.open("127.0.0.1", server!!.localPort, 12345, timeoutMs = 5000)!! + connection.use { + assertEquals(SERVER, it.info.serverId) + val sync = it.syncConnection() + assertEquals(SERVER, sync.serverId) + val manifest = sync.manifest(RequestSyncManifest.newBuilder().setSourceId("playlist/1").build()) + assertEquals("playlist/1", manifest.sourceId) + } + } + + @Test + fun givesUpWhenClementineRefusesTheCode() { + clementine( + Message.newBuilder().setType(MsgType.DISCONNECT).setResponseDisconnect( + ResponseDisconnect.newBuilder().setReasonDisconnect(ReasonDisconnect.Wrong_Auth_Code) + ) + ) + assertNull(BackgroundConnection.open("127.0.0.1", server!!.localPort, 1, timeoutMs = 5000)) + } + + @Test + fun givesUpWhenNothingIsThere() { + val free = ServerSocket(0, 1, InetAddress.getLoopbackAddress()).use { it.localPort } + assertNull(BackgroundConnection.open("127.0.0.1", free, 12345, timeoutMs = 5000)) + } + + private companion object { + const val SERVER = "4f1c2d6e-8a0b-4c8e-9b7a-2e3f5d6c7b8a" + } +} diff --git a/app/src/test/java/de/qspool/clementineremote/backend/sync/SyncEngineTest.kt b/app/src/test/java/de/qspool/clementineremote/backend/sync/SyncEngineTest.kt index 47efc0d3..6fc5c048 100644 --- a/app/src/test/java/de/qspool/clementineremote/backend/sync/SyncEngineTest.kt +++ b/app/src/test/java/de/qspool/clementineremote/backend/sync/SyncEngineTest.kt @@ -353,6 +353,30 @@ class SyncEngineTest { assertEquals(listOf("/d/token/c-1"), clementine.opened) } + @Test + fun aStoppedSyncDownloadsNothingMoreAndKeepsWhatItHas() { + var stop = false + engine = SyncEngine( + store, storage, storage::delete, + listOf(AudioFormat.newBuilder().setMimeType("audio/flac").build()), + onProgress = { _, done, _ -> if (done == 1) stop = true }, + stopped = { stop }, + ) + val album = rule("library/album/Pixies/Doolittle") + val playlist = rule("playlist/1") + manifest("library/album/Pixies/Doolittle", song("a"), song("b", track = 2)) + manifest("playlist/1", song("c")) + + val result = engine.sync(clementine) + + // Stopped after the first song: the album keeps it, and the playlist isn't tried. + assertTrue(result.rules[album] is RuleResult.Failed) + assertNull(result.rules[playlist]) + assertEquals(listOf("a"), store.ruleItems(album).map { it.itemKey }) + assertEquals(listOf("/d/token/a-1"), clementine.opened) + assertEquals(0, result.removed) + } + @Test fun namesFilesForPeople() { val song = SongMetadata.newBuilder().setTitle("What/Ever?").setAlbumartist("A: B")