feat: auto-sync the library on a timer while the app is open

While the app is in the foreground a timer checks the auto-sync setting and
syncs when due (default every 24h; the interval is selectable from 15 minutes
to 24 hours). It stops when the app is backgrounded. "Sync automatically"
is one setting: Off, Wi-Fi only (default) or Always.

A running sync is shown by a silent, dismissible notification and a small
spinner on the library tab bar's settings button. A manual pull-to-refresh
still holds the pull spinner; only an automatic sync does not. The library
and Home sync actions no-op while a sync is running, and SyncManager
coalesces a request that arrives mid-run into a single follow-up, so repeated
pull-to-refresh cannot pile up runs while adding a server still syncs it.

The time of the last sync attempt is recorded, so a failing server is retried
once per interval rather than every poll. The sync consumer and the auto-sync
loop both survive an unexpected failure instead of silently disabling syncing
for the rest of the process.

The poll sleeps for the interval (capped at 15 minutes) instead of a fixed
minute, the network mode is collected once rather than re-registering a
connectivity callback each poll, and the decision is logged at DEBUG (visible
with Verbose logging) rather than writing to the log file every poll.
This commit is contained in:
austinried
2026-10-10 03:36:16 +00:00
parent 8e7431cc2e
commit e396671ac7
17 changed files with 653 additions and 34 deletions
@@ -0,0 +1,146 @@
package com.subtracks.data.sync
import com.subtracks.awaitUntil
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.net.NetworkMode
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
import com.subtracks.data.prefs.SyncMode
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
class AutoSyncTest {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@After
fun tearDown() {
cancelAndJoinBlocking(scope)
}
@Test
fun decidesWhetherASyncIsDue() {
assertTrue(shouldSync())
assertTrue(shouldSync(mode = SyncMode.WifiOnly))
assertTrue(shouldSync(mode = SyncMode.Always, network = NetworkMode.Mobile))
assertTrue(shouldSync(lastSyncAt = INTERVAL_MS * 2, now = INTERVAL_MS))
assertFalse(shouldSync(mode = SyncMode.Off))
assertFalse(shouldSync(mode = SyncMode.WifiOnly, network = NetworkMode.Mobile))
assertFalse(shouldSync(lastSyncAt = INTERVAL_MS - 1, now = INTERVAL_MS))
assertFalse(shouldSync(offline = true))
assertFalse(shouldSync(hasSource = false))
}
@Test
fun syncsWhenTheAppIsActiveAndDue() {
val preferences = fakeUserPreferences()
runBlocking {
preferences.setSyncMode(SyncMode.WifiOnly)
preferences.setLastSyncAt(0L)
}
val requested = AtomicBoolean(false)
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
autoSync.setForeground(true)
awaitUntil("auto sync requested") { requested.get() }
autoSync.setForeground(false)
}
@Test
fun doesNotSyncWhenOff() {
val preferences = fakeUserPreferences()
runBlocking {
preferences.setSyncMode(SyncMode.Off)
preferences.setLastSyncAt(0L)
}
val requested = AtomicBoolean(false)
val autoSync = autoSync(preferences, requestSync = { requested.set(true) })
autoSync.setForeground(true)
runBlocking { delay(300) }
autoSync.setForeground(false)
assertFalse(requested.get())
}
@Test
fun stopsSyncingWhenBackgrounded() {
val preferences = fakeUserPreferences()
runBlocking {
preferences.setSyncMode(SyncMode.Always)
preferences.setLastSyncAt(0L)
}
val count = AtomicInteger(0)
val autoSync =
AutoSync(
preferences = preferences,
networkMode = flowOf(NetworkMode.Wifi),
hasSource = { true },
requestSync = { count.incrementAndGet() },
scope = scope,
now = { INTERVAL_MS + 1 },
maxPollMs = 50L,
)
autoSync.setForeground(true)
awaitUntil("first sync") { count.get() >= 1 }
autoSync.setForeground(false)
runBlocking { delay(200) }
val afterStop = count.get()
runBlocking { delay(300) }
assertEquals(afterStop, count.get())
}
private fun autoSync(
preferences: UserPreferences,
requestSync: () -> Unit,
now: () -> Long = { INTERVAL_MS + 1 },
): AutoSync =
AutoSync(
preferences = preferences,
networkMode = flowOf(NetworkMode.Wifi),
hasSource = { true },
requestSync = requestSync,
scope = scope,
now = now,
maxPollMs = 10_000L,
)
private fun shouldSync(
mode: SyncMode = SyncMode.WifiOnly,
network: NetworkMode = NetworkMode.Wifi,
offline: Boolean = false,
hasSource: Boolean = true,
lastSyncAt: Long = 0L,
now: Long = INTERVAL_MS,
intervalMs: Long = INTERVAL_MS,
): Boolean =
shouldAutoSync(
mode = mode,
network = network,
offline = offline,
hasSource = hasSource,
lastSyncAt = lastSyncAt,
now = now,
intervalMs = intervalMs,
)
private companion object {
val INTERVAL_MS = DEFAULT_SYNC_INTERVAL_MINUTES * 60_000L
}
}
@@ -10,6 +10,7 @@ import com.subtracks.TEST_TIMEOUT_MS
import com.subtracks.cancelAndJoinBlocking
import com.subtracks.data.db.SubtracksDatabase
import com.subtracks.data.download.ArtworkStore
import com.subtracks.data.prefs.UserPreferences
import com.subtracks.data.prefs.fakeUserPreferences
import com.subtracks.data.repo.NetworkServerActionSink
import com.subtracks.data.repo.QueueRepository
@@ -44,6 +45,7 @@ class SyncManagerTest {
private lateinit var sourceRepository: SourceRepository
private lateinit var queueRepository: QueueRepository
private lateinit var manager: SyncManager
private lateinit var preferences: UserPreferences
private lateinit var scope: CoroutineScope
private val messages = CopyOnWriteArrayList<String>()
private lateinit var server: MockWebServer
@@ -62,12 +64,14 @@ class SyncManagerTest {
sourceRepository =
SourceRepository(db, OkHttpClient(), fakeUserPreferences(), ArtworkStore(File(context.cacheDir, "art")), scope = scope)
queueRepository = QueueRepository(db)
preferences = fakeUserPreferences()
manager =
SyncManager(
db,
sourceRepository,
queueRepository,
NetworkServerActionSink(sourceRepository),
preferences = preferences,
showMessage = { messages += it.resolve(resources) },
scope = scope,
)
@@ -89,6 +93,16 @@ class SyncManagerTest {
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertEquals(listOf("No server configured"), messages)
assertTrue(preferences.lastSyncAt().first() > 0)
}
@Test
fun aSilentSyncDoesNotTellTheUser() =
runBlocking {
manager.requestSync(silent = true)
withTimeout(TEST_TIMEOUT_MS) { manager.status.first { it is SyncStatus.Failed } }
assertTrue(messages.isEmpty())
}
@Test
@@ -114,6 +128,7 @@ class SyncManagerTest {
assertEquals(listOf("ar1"), db.libraryDao().artistIds(1))
assertEquals(listOf("al1"), db.libraryDao().albumIds(1))
assertTrue(preferences.lastSyncAt().first() > 0)
}
@Test
@@ -156,7 +156,14 @@ class AddSourceViewModelTest {
private fun viewModel(sourceId: Long? = null): AddSourceViewModel {
val syncManager =
SyncManager(db, sourceRepository, QueueRepository(db), NetworkServerActionSink(sourceRepository), scope = repoScope)
SyncManager(
db,
sourceRepository,
QueueRepository(db),
NetworkServerActionSink(sourceRepository),
preferences = fakeUserPreferences(),
scope = repoScope,
)
val resources = ApplicationProvider.getApplicationContext<Context>().resources
return AddSourceViewModel(sourceRepository, syncManager, downloadRepository, sourceId, resources).also { createdViewModels += it }
}