mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 23:21:59 +01:00
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:
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user