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