Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,16 @@
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/download_paths" />
</provider>

<!-- WorkManager is set up when it's first used, from App's configuration. -->
<provider android:name="androidx.startup.InitializationProvider"
android:authorities="${applicationId}.androidx-startup"
android:exported="false"
tools:node="merge">
<meta-data android:name="androidx.work.WorkManagerInitializer"
android:value="androidx.startup"
tools:node="remove" />
</provider>
</application>

</manifest>
18 changes: 17 additions & 1 deletion app/src/main/java/de/qspool/clementineremote/App.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Original file line number Diff line number Diff line change
@@ -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<ResponseSyncManifest>,
) : 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<ResponseSyncManifest>(
extraBufferCapacity = 16, onBufferOverflow = BufferOverflow.DROP_OLDEST,
)
val info = CompletableDeferred<ResponseClementineInfo?>()
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)
}
}
}
Original file line number Diff line number Diff line change
@@ -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<SyncWorker>(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<SyncRule>): 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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -68,12 +70,14 @@ class SyncEngine(
private val supportedFormats: List<AudioFormat>,
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<Long, RuleResult>()
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
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading
Loading