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")