mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-10 15:11:58 +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:
@@ -11,6 +11,7 @@ import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.activity.viewModels
|
||||
import androidx.core.content.ContextCompat
|
||||
import com.subtracks.data.sync.AutoSync
|
||||
import com.subtracks.ui.NowPlayingLauncher
|
||||
import com.subtracks.ui.SubtracksRoot
|
||||
import com.subtracks.ui.theme.SubtracksTheme
|
||||
@@ -19,10 +20,21 @@ import org.koin.core.context.GlobalContext
|
||||
class MainActivity : ComponentActivity() {
|
||||
private val launchViewModel: NowPlayingLaunchViewModel by viewModels()
|
||||
private val nowPlayingLauncher: NowPlayingLauncher by lazy { GlobalContext.get().get() }
|
||||
private val autoSync: AutoSync by lazy { GlobalContext.get().get() }
|
||||
|
||||
private val requestNotifications =
|
||||
registerForActivityResult(ActivityResultContracts.RequestPermission()) { }
|
||||
|
||||
override fun onStart() {
|
||||
super.onStart()
|
||||
autoSync.setForeground(true)
|
||||
}
|
||||
|
||||
override fun onStop() {
|
||||
super.onStop()
|
||||
autoSync.setForeground(false)
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
requestNotificationPermission()
|
||||
|
||||
@@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.booleanPreferencesKey
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.intPreferencesKey
|
||||
import androidx.datastore.preferences.core.longPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.preferencesDataStore
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
@@ -48,6 +49,10 @@ data class StreamQuality(
|
||||
|
||||
private val Context.preferences: DataStore<Preferences> by preferencesDataStore("user_prefs")
|
||||
|
||||
const val DEFAULT_SYNC_INTERVAL_MINUTES = 24 * 60
|
||||
|
||||
enum class SyncMode { Off, WifiOnly, Always }
|
||||
|
||||
data class ArtworkSeedValue(
|
||||
val cacheKey: String,
|
||||
val primary: Int,
|
||||
@@ -105,6 +110,34 @@ class UserPreferences(
|
||||
|
||||
private val syncConcurrencyKey = intPreferencesKey("sync_concurrency")
|
||||
|
||||
fun syncMode(): Flow<SyncMode> =
|
||||
store.data.map { prefs ->
|
||||
prefs[syncModeKey]?.let { stored -> SyncMode.entries.firstOrNull { it.name == stored } } ?: SyncMode.WifiOnly
|
||||
}
|
||||
|
||||
suspend fun setSyncMode(mode: SyncMode) {
|
||||
store.edit { prefs -> prefs[syncModeKey] = mode.name }
|
||||
}
|
||||
|
||||
private val syncModeKey = stringPreferencesKey("sync_mode")
|
||||
|
||||
fun lastSyncAt(): Flow<Long> = store.data.map { prefs -> prefs[lastSyncAtKey] ?: 0L }
|
||||
|
||||
suspend fun setLastSyncAt(value: Long) {
|
||||
store.edit { prefs -> prefs[lastSyncAtKey] = value }
|
||||
}
|
||||
|
||||
private val lastSyncAtKey = longPreferencesKey("last_sync_at")
|
||||
|
||||
fun syncIntervalMinutes(): Flow<Int> =
|
||||
store.data.map { prefs -> (prefs[syncIntervalKey] ?: DEFAULT_SYNC_INTERVAL_MINUTES).coerceAtLeast(1) }
|
||||
|
||||
suspend fun setSyncIntervalMinutes(value: Int) {
|
||||
store.edit { prefs -> prefs[syncIntervalKey] = value.coerceAtLeast(1) }
|
||||
}
|
||||
|
||||
private val syncIntervalKey = intPreferencesKey("sync_interval_minutes")
|
||||
|
||||
fun scrobbling(): Flow<Boolean> = store.data.map { prefs -> prefs[scrobblingKey] ?: true }
|
||||
|
||||
suspend fun setScrobbling(enabled: Boolean) {
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.log.Log
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
internal const val AUTO_SYNC_MAX_POLL_MS = 15L * 60 * 1000
|
||||
|
||||
internal fun shouldAutoSync(
|
||||
mode: SyncMode,
|
||||
network: NetworkMode,
|
||||
offline: Boolean,
|
||||
hasSource: Boolean,
|
||||
lastSyncAt: Long,
|
||||
now: Long,
|
||||
intervalMs: Long,
|
||||
): Boolean {
|
||||
val allowed =
|
||||
when (mode) {
|
||||
SyncMode.Off -> false
|
||||
SyncMode.WifiOnly -> network == NetworkMode.Wifi
|
||||
SyncMode.Always -> true
|
||||
}
|
||||
return allowed && !offline && hasSource && (lastSyncAt > now || now - lastSyncAt >= intervalMs)
|
||||
}
|
||||
|
||||
class AutoSync(
|
||||
private val preferences: UserPreferences,
|
||||
private val networkMode: Flow<NetworkMode>,
|
||||
private val hasSource: suspend () -> Boolean,
|
||||
private val requestSync: () -> Unit,
|
||||
private val scope: CoroutineScope,
|
||||
private val now: () -> Long = System::currentTimeMillis,
|
||||
private val maxPollMs: Long = AUTO_SYNC_MAX_POLL_MS,
|
||||
) {
|
||||
@Volatile
|
||||
private var lastNetwork: NetworkMode? = null
|
||||
private var loop: Job? = null
|
||||
|
||||
init {
|
||||
scope.launch { networkMode.collect { lastNetwork = it } }
|
||||
}
|
||||
|
||||
fun setForeground(active: Boolean) {
|
||||
if (active) start() else stop()
|
||||
}
|
||||
|
||||
private fun start() {
|
||||
if (loop?.isActive == true) return
|
||||
loop =
|
||||
scope.launch {
|
||||
while (isActive) {
|
||||
val intervalMs =
|
||||
try {
|
||||
checkAndRequest()
|
||||
} catch (cancellation: CancellationException) {
|
||||
throw cancellation
|
||||
} catch (failure: Exception) {
|
||||
Log.w("sync", "auto-sync check failed", failure)
|
||||
maxPollMs
|
||||
}
|
||||
delay(intervalMs.coerceAtMost(maxPollMs))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun stop() {
|
||||
loop?.cancel()
|
||||
loop = null
|
||||
}
|
||||
|
||||
private suspend fun checkAndRequest(): Long {
|
||||
val mode = preferences.syncMode().first()
|
||||
val network = lastNetwork ?: networkMode.first()
|
||||
val offline = preferences.offlineMode().first()
|
||||
val hasSource = hasSource()
|
||||
val lastSyncAt = preferences.lastSyncAt().first()
|
||||
val intervalMs = preferences.syncIntervalMinutes().first() * 60_000L
|
||||
val time = now()
|
||||
val due =
|
||||
shouldAutoSync(
|
||||
mode = mode,
|
||||
network = network,
|
||||
offline = offline,
|
||||
hasSource = hasSource,
|
||||
lastSyncAt = lastSyncAt,
|
||||
now = time,
|
||||
intervalMs = intervalMs,
|
||||
)
|
||||
Log.d(
|
||||
"sync",
|
||||
"auto-sync due=$due (mode=$mode, network=$network, offline=$offline, hasSource=$hasSource, " +
|
||||
"sinceLast=${time - lastSyncAt}ms, interval=${intervalMs}ms)",
|
||||
)
|
||||
if (due && currentCoroutineContext().isActive) requestSync()
|
||||
return intervalMs
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package com.subtracks.data.sync
|
||||
import com.subtracks.R
|
||||
import com.subtracks.UiMessage
|
||||
import com.subtracks.data.db.SubtracksDatabase
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
@@ -13,14 +14,18 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.channels.Channel
|
||||
import kotlinx.coroutines.currentCoroutineContext
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.isActive
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
sealed interface SyncStatus {
|
||||
data object Idle : SyncStatus
|
||||
|
||||
data object Running : SyncStatus
|
||||
data class Running(
|
||||
val silent: Boolean,
|
||||
) : SyncStatus
|
||||
|
||||
data object Success : SyncStatus
|
||||
|
||||
@@ -34,31 +39,47 @@ class SyncManager(
|
||||
private val sourceRepository: SourceRepository,
|
||||
private val queueRepository: QueueRepository,
|
||||
private val serverActions: ServerActionSink,
|
||||
private val preferences: UserPreferences,
|
||||
private val showMessage: (UiMessage) -> Unit = {},
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
) {
|
||||
private val requests = Channel<Unit>(Channel.CONFLATED)
|
||||
private val requests = Channel<Boolean>(Channel.CONFLATED)
|
||||
private val _status = MutableStateFlow<SyncStatus>(SyncStatus.Idle)
|
||||
val status: StateFlow<SyncStatus> = _status
|
||||
|
||||
init {
|
||||
scope.launch {
|
||||
for (ignored in requests) runSync()
|
||||
for (silent in requests) {
|
||||
try {
|
||||
runSync(silent)
|
||||
} catch (cancellation: CancellationException) {
|
||||
if (!currentCoroutineContext().isActive) throw cancellation
|
||||
_status.value = SyncStatus.Idle
|
||||
Log.w("sync", "sync interrupted", cancellation)
|
||||
} catch (failure: Throwable) {
|
||||
_status.value = SyncStatus.Idle
|
||||
Log.w("sync", "unexpected failure", failure)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun requestSync() {
|
||||
requests.trySend(Unit)
|
||||
fun requestSync(silent: Boolean = false) {
|
||||
requests.trySend(silent)
|
||||
}
|
||||
|
||||
private suspend fun runSync() {
|
||||
private suspend fun runSync(silent: Boolean) {
|
||||
if (sourceRepository.offline.value) {
|
||||
val offline = UiMessage(R.string.sync_offline)
|
||||
showMessage(offline)
|
||||
_status.value = SyncStatus.Failed(offline)
|
||||
if (!silent) {
|
||||
val offline = UiMessage(R.string.sync_offline)
|
||||
showMessage(offline)
|
||||
_status.value = SyncStatus.Failed(offline)
|
||||
}
|
||||
return
|
||||
}
|
||||
_status.value = SyncStatus.Running
|
||||
_status.value = SyncStatus.Running(silent)
|
||||
runCatching { preferences.setLastSyncAt(System.currentTimeMillis()) }
|
||||
.onFailure { Log.w("sync", "could not record the sync attempt", it) }
|
||||
val startedAt = System.nanoTime()
|
||||
val result =
|
||||
try {
|
||||
@@ -88,7 +109,7 @@ class SyncManager(
|
||||
?: UiMessage(R.string.sync_failed_detail, listOf(failure.message ?: ""))
|
||||
}
|
||||
}
|
||||
showMessage(message)
|
||||
if (!silent) showMessage(message)
|
||||
SyncStatus.Failed(message)
|
||||
}
|
||||
queueRepository.invalidateLibraryCache()
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
package com.subtracks.data.sync
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import com.subtracks.R
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class SyncNotifier(
|
||||
private val context: Context,
|
||||
private val status: Flow<SyncStatus>,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default),
|
||||
) {
|
||||
private val manager = NotificationManagerCompat.from(context)
|
||||
|
||||
fun start() {
|
||||
createChannel()
|
||||
scope.launch {
|
||||
status.collect { syncStatus ->
|
||||
if (syncStatus is SyncStatus.Running && manager.areNotificationsEnabled()) {
|
||||
manager.notify(ID, build())
|
||||
} else {
|
||||
manager.cancel(ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun build(): Notification =
|
||||
NotificationCompat
|
||||
.Builder(context, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.ic_stat_name)
|
||||
.setContentTitle(context.getString(R.string.sync_notification_title))
|
||||
.setContentText(context.getString(R.string.sync_notification_text))
|
||||
.setOnlyAlertOnce(true)
|
||||
.setSilent(true)
|
||||
.setProgress(0, 0, true)
|
||||
.setContentIntent(launchIntent())
|
||||
.build()
|
||||
|
||||
private fun launchIntent(): PendingIntent? =
|
||||
context.packageManager
|
||||
.getLaunchIntentForPackage(context.packageName)
|
||||
?.let { PendingIntent.getActivity(context, 0, it, PendingIntent.FLAG_IMMUTABLE) }
|
||||
|
||||
private fun createChannel() {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val manager = context.getSystemService(NotificationManager::class.java) ?: return
|
||||
if (manager.getNotificationChannel(CHANNEL_ID) != null) return
|
||||
manager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
CHANNEL_ID,
|
||||
context.getString(R.string.sync_notification_channel),
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
).apply { setShowBadge(false) },
|
||||
)
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ID = 2
|
||||
private const val CHANNEL_ID = "sync"
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,9 @@ import com.subtracks.data.repo.PendingServerActionSink
|
||||
import com.subtracks.data.repo.QueueRepository
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.ServerActionSink
|
||||
import com.subtracks.data.sync.AutoSync
|
||||
import com.subtracks.data.sync.SyncManager
|
||||
import com.subtracks.data.sync.SyncNotifier
|
||||
import com.subtracks.log.Log
|
||||
import com.subtracks.playback.MediaSessionConnection
|
||||
import com.subtracks.playback.PlaybackController
|
||||
@@ -96,7 +98,16 @@ fun appModule(
|
||||
}
|
||||
single { LibraryRepository(get(), get(), get(), toast, scope = get(named("io"))) }
|
||||
single<ArtworkSeedStore> { ArtworkSeedRepository(get()) }
|
||||
single { SyncManager(get(), get(), get(), get(), showMessage = toast, scope = get()) }
|
||||
single { SyncManager(get(), get(), get(), get(), preferences = get(), showMessage = toast, scope = get()) }
|
||||
single {
|
||||
AutoSync(
|
||||
preferences = get(),
|
||||
networkMode = networkMode(context.applicationContext),
|
||||
hasSource = { get<SourceRepository>().activeSourceIdOnce() != null },
|
||||
requestSync = { get<SyncManager>().requestSync(silent = true) },
|
||||
scope = get(),
|
||||
)
|
||||
}
|
||||
single { QueueRepository(get()) }
|
||||
single { ArtworkStore(downloadsRoot(context)) }
|
||||
single<ArtworkFetcher> { OkHttpArtworkFetcher(get()) }
|
||||
@@ -115,6 +126,7 @@ fun appModule(
|
||||
).also { it.start() }
|
||||
}
|
||||
single(createdAtStart = true) { DownloadNotifier(context.applicationContext, get(), scope = get()).also { it.start() } }
|
||||
single(createdAtStart = true) { SyncNotifier(context.applicationContext, get<SyncManager>().status, scope = get()).also { it.start() } }
|
||||
single<PlayerConnection> { MediaSessionConnection(context.applicationContext, get(), get()) }
|
||||
single { PlaybackController(get(), get(), get(), get(), showMessage = toast, scope = get(named("playback"))) }
|
||||
single(createdAtStart = true) {
|
||||
|
||||
@@ -25,7 +25,6 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filterNotNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -53,11 +52,6 @@ class HomeViewModel(
|
||||
private val syncManager: SyncManager,
|
||||
private val playbackController: PlaybackController,
|
||||
) : ViewModel() {
|
||||
val syncing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it == SyncStatus.Running }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
val offline: StateFlow<Boolean> =
|
||||
sourceRepository.offline.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
@@ -141,7 +135,9 @@ class HomeViewModel(
|
||||
viewModelScope.playDownloadedList(libraryRepository, playbackController, song)
|
||||
}
|
||||
|
||||
fun sync() = syncManager.requestSync()
|
||||
fun sync() {
|
||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||
}
|
||||
|
||||
private data class PlayRows(
|
||||
val recentAlbums: List<Album>,
|
||||
|
||||
@@ -40,6 +40,7 @@ import androidx.compose.material.icons.rounded.Person
|
||||
import androidx.compose.material.icons.rounded.Remove
|
||||
import androidx.compose.material.icons.rounded.Search
|
||||
import androidx.compose.material.icons.rounded.Settings
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.FilterChip
|
||||
import androidx.compose.material3.FilterChipDefaults
|
||||
@@ -243,6 +244,7 @@ fun LibraryRoute(
|
||||
var previousTabName by remember { mutableStateOf(selectedTab.name) }
|
||||
val previousTab = LibraryTab.entries.firstOrNull { it.name == previousTabName } ?: LibraryTab.Albums
|
||||
val syncing by viewModel.syncing.collectAsStateWithLifecycle()
|
||||
val refreshing by viewModel.refreshing.collectAsStateWithLifecycle()
|
||||
val listTab = selectedTab.listTab()
|
||||
LaunchedEffect(selectedTab) {
|
||||
if (previousTab != selectedTab) {
|
||||
@@ -296,6 +298,7 @@ fun LibraryRoute(
|
||||
selectedTab = selectedTab,
|
||||
onTabSelected = { tab -> if (viewModel.selectedTab.value != tab) viewModel.selectTab(tab) },
|
||||
syncing = syncing,
|
||||
refreshing = refreshing,
|
||||
bottomInset = bottomInset,
|
||||
artwork = artwork,
|
||||
albums = viewModel.albums,
|
||||
@@ -392,6 +395,7 @@ fun LibraryScreen(
|
||||
onExitOffline: () -> Unit = {},
|
||||
onItemLongClick: (MenuTarget) -> Unit = {},
|
||||
syncing: Boolean = false,
|
||||
refreshing: Boolean = false,
|
||||
bottomInset: Dp = 0.dp,
|
||||
listQuery: ListQuery = ListQuery(""),
|
||||
resetKeys: Map<LibraryTab, Any?> = emptyMap(),
|
||||
@@ -474,13 +478,13 @@ fun LibraryScreen(
|
||||
val scrollToTopKey = if (pageTab == selectedTab) topRequest else null
|
||||
val pullToRefreshState = rememberPullToRefreshState()
|
||||
PullToRefreshBox(
|
||||
isRefreshing = syncing,
|
||||
isRefreshing = refreshing,
|
||||
onRefresh = onSync,
|
||||
state = pullToRefreshState,
|
||||
indicator = {
|
||||
PullToRefreshDefaults.Indicator(
|
||||
state = pullToRefreshState,
|
||||
isRefreshing = syncing,
|
||||
isRefreshing = refreshing,
|
||||
modifier = Modifier.align(Alignment.TopCenter).statusBarsPadding(),
|
||||
containerColor = headerColor,
|
||||
color = artwork?.scheme?.primary ?: MaterialTheme.colorScheme.primary,
|
||||
@@ -582,6 +586,7 @@ fun LibraryScreen(
|
||||
offline = offline,
|
||||
onExitOffline = onExitOffline,
|
||||
artwork = artwork,
|
||||
syncing = syncing,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -848,6 +853,7 @@ internal fun LibraryTabs(
|
||||
offline: Boolean = false,
|
||||
onExitOffline: () -> Unit = {},
|
||||
artwork: ArtworkColors?,
|
||||
syncing: Boolean = false,
|
||||
) {
|
||||
val indicatorStretch = 18.dp
|
||||
val density = LocalDensity.current
|
||||
@@ -939,11 +945,20 @@ internal fun LibraryTabs(
|
||||
}
|
||||
}
|
||||
IconButton(onClick = onOpenSettings) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.Settings,
|
||||
contentDescription = stringResource(R.string.navigation_tabs_settings),
|
||||
tint = MaterialTheme.colorScheme.onBackground,
|
||||
)
|
||||
if (syncing) {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(32.dp),
|
||||
strokeWidth = 2.dp,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,12 @@ class LibraryViewModel(
|
||||
|
||||
val syncing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it == SyncStatus.Running }
|
||||
.map { it is SyncStatus.Running }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
val refreshing: StateFlow<Boolean> =
|
||||
syncManager.status
|
||||
.map { it is SyncStatus.Running && !it.silent }
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
|
||||
|
||||
private val activeSourceId = libraryRepository.activeSourceId.filterNotNull()
|
||||
@@ -206,7 +211,9 @@ class LibraryViewModel(
|
||||
thumbnail: Boolean,
|
||||
): CoverArtRef? = sourceRepository.coverArt(coverArt, thumbnail)
|
||||
|
||||
fun sync() = syncManager.requestSync()
|
||||
fun sync() {
|
||||
if (syncManager.status.value !is SyncStatus.Running) syncManager.requestSync()
|
||||
}
|
||||
}
|
||||
|
||||
internal fun ListQuery.albumSort(): AlbumSort = AlbumSort.entries.firstOrNull { it.name == sort } ?: AlbumSort.Name
|
||||
|
||||
@@ -65,7 +65,9 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import com.subtracks.BuildConfig
|
||||
import com.subtracks.R
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.log.Log
|
||||
import com.subtracks.ui.components.DismissOnRequest
|
||||
import com.subtracks.ui.components.rememberViewportFill
|
||||
@@ -82,7 +84,7 @@ private val streamFormats = listOf(null, "mp3", "opus", "ogg", "webm", "aac", "f
|
||||
private const val PROJECT_HOMEPAGE = "https://github.com/austinried/subtracks"
|
||||
private const val SUPPORT_URL = "https://ko-fi.com/austinried"
|
||||
|
||||
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency }
|
||||
private enum class SettingsDialog { WifiQuality, MobileQuality, DownloadQuality, SyncConcurrency, SyncInterval, SyncMode }
|
||||
|
||||
@Composable
|
||||
fun SettingsRoute(
|
||||
@@ -99,6 +101,8 @@ fun SettingsRoute(
|
||||
val wifiQuality by viewModel.wifiQuality.collectAsStateWithLifecycle()
|
||||
val mobileQuality by viewModel.mobileQuality.collectAsStateWithLifecycle()
|
||||
val syncConcurrency by viewModel.syncConcurrency.collectAsStateWithLifecycle()
|
||||
val syncMode by viewModel.syncMode.collectAsStateWithLifecycle()
|
||||
val syncIntervalMinutes by viewModel.syncIntervalMinutes.collectAsStateWithLifecycle()
|
||||
val downloadQuality by viewModel.downloadQuality.collectAsStateWithLifecycle()
|
||||
val downloadOverMetered by viewModel.downloadOverMetered.collectAsStateWithLifecycle()
|
||||
val scrobbling by viewModel.scrobbling.collectAsStateWithLifecycle()
|
||||
@@ -112,6 +116,8 @@ fun SettingsRoute(
|
||||
wifiQuality = wifiQuality,
|
||||
mobileQuality = mobileQuality,
|
||||
syncConcurrency = syncConcurrency,
|
||||
syncMode = syncMode,
|
||||
syncInterval = syncIntervalMinutes,
|
||||
downloadQuality = downloadQuality,
|
||||
downloadOverMetered = downloadOverMetered,
|
||||
scrobbling = scrobbling,
|
||||
@@ -122,6 +128,8 @@ fun SettingsRoute(
|
||||
onWifiQualityChange = viewModel::setWifiQuality,
|
||||
onMobileQualityChange = viewModel::setMobileQuality,
|
||||
onSyncConcurrencyChange = viewModel::setSyncConcurrency,
|
||||
onSyncModeChange = viewModel::setSyncMode,
|
||||
onSyncIntervalChange = viewModel::setSyncIntervalMinutes,
|
||||
onDownloadQualityChange = viewModel::setDownloadQuality,
|
||||
onDownloadOverMeteredChange = viewModel::setDownloadOverMetered,
|
||||
onScrobblingChange = viewModel::setScrobbling,
|
||||
@@ -166,11 +174,15 @@ fun SettingsScreen(
|
||||
modifier: Modifier = Modifier,
|
||||
offline: Boolean = false,
|
||||
verboseLogging: Boolean = false,
|
||||
syncMode: SyncMode = SyncMode.WifiOnly,
|
||||
syncInterval: Int = DEFAULT_SYNC_INTERVAL_MINUTES,
|
||||
onSelectSource: (Long) -> Unit,
|
||||
onEditServer: (Long) -> Unit,
|
||||
onWifiQualityChange: (StreamQuality) -> Unit,
|
||||
onMobileQualityChange: (StreamQuality) -> Unit,
|
||||
onSyncConcurrencyChange: (Int) -> Unit,
|
||||
onSyncModeChange: (SyncMode) -> Unit = {},
|
||||
onSyncIntervalChange: (Int) -> Unit = {},
|
||||
onDownloadQualityChange: (StreamQuality) -> Unit,
|
||||
onDownloadOverMeteredChange: (Boolean) -> Unit,
|
||||
onScrobblingChange: (Boolean) -> Unit,
|
||||
@@ -288,6 +300,22 @@ fun SettingsScreen(
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||
supportingContent = { Text(syncModeLabel(LocalResources.current, syncMode)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SyncMode },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item {
|
||||
ListItem(
|
||||
headlineContent = { Text(stringResource(R.string.sync_interval)) },
|
||||
supportingContent = { Text(syncIntervalLabel(LocalResources.current, syncInterval)) },
|
||||
modifier = Modifier.clickable { dialog = SettingsDialog.SyncInterval },
|
||||
colors = ListItemDefaults.colors(containerColor = Color.Transparent),
|
||||
)
|
||||
}
|
||||
item { SectionHeader(stringResource(R.string.settings_music_name)) }
|
||||
item {
|
||||
ListItem(
|
||||
@@ -475,6 +503,22 @@ fun SettingsScreen(
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.SyncInterval -> {
|
||||
SyncIntervalDialog(
|
||||
selected = syncInterval,
|
||||
onSelect = onSyncIntervalChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
SettingsDialog.SyncMode -> {
|
||||
SyncModeDialog(
|
||||
selected = syncMode,
|
||||
onSelect = onSyncModeChange,
|
||||
onDismiss = { dialog = null },
|
||||
)
|
||||
}
|
||||
|
||||
null -> {
|
||||
Unit
|
||||
}
|
||||
@@ -500,11 +544,56 @@ private fun bitrateLabel(
|
||||
|
||||
private val syncConcurrencyOptions = listOf(1, 2, 4, 8, 16)
|
||||
|
||||
private val syncIntervalOptions = listOf(15, 60, 360, 720, DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||
|
||||
private fun concurrencyLabel(
|
||||
resources: Resources,
|
||||
value: Int,
|
||||
): String = if (value <= 1) resources.getString(R.string.sync_concurrency_sequential) else value.toString()
|
||||
|
||||
private fun syncIntervalLabel(
|
||||
resources: Resources,
|
||||
minutes: Int,
|
||||
): String =
|
||||
if (minutes >= 60 && minutes % 60 == 0) {
|
||||
resources.getQuantityString(R.plurals.sync_interval_hours, minutes / 60, minutes / 60)
|
||||
} else {
|
||||
resources.getQuantityString(R.plurals.sync_interval_minutes, minutes, minutes)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncIntervalDialog(
|
||||
selected: Int,
|
||||
onSelect: (Int) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableIntStateOf(selected) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.sync_interval)) },
|
||||
text = {
|
||||
ChoiceGroup(
|
||||
header = stringResource(R.string.sync_interval_prompt),
|
||||
options = syncIntervalOptions.map { it to syncIntervalLabel(resources, it) },
|
||||
selected = draft,
|
||||
onSelect = { draft = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ConcurrencyDialog(
|
||||
selected: Int,
|
||||
@@ -538,6 +627,51 @@ private fun ConcurrencyDialog(
|
||||
)
|
||||
}
|
||||
|
||||
private val syncModeOptions = SyncMode.entries
|
||||
|
||||
private fun syncModeLabel(
|
||||
resources: Resources,
|
||||
mode: SyncMode,
|
||||
): String =
|
||||
when (mode) {
|
||||
SyncMode.Off -> resources.getString(R.string.sync_mode_off)
|
||||
SyncMode.WifiOnly -> resources.getString(R.string.sync_mode_wifi)
|
||||
SyncMode.Always -> resources.getString(R.string.sync_mode_always)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SyncModeDialog(
|
||||
selected: SyncMode,
|
||||
onSelect: (SyncMode) -> Unit,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
val resources = LocalResources.current
|
||||
var draft by remember(selected) { mutableStateOf(selected) }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text(stringResource(R.string.settings_sync_auto_title)) },
|
||||
text = {
|
||||
ChoiceGroup(
|
||||
header = stringResource(R.string.sync_mode_prompt),
|
||||
options = syncModeOptions.map { it to syncModeLabel(resources, it) },
|
||||
selected = draft,
|
||||
onSelect = { draft = it },
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(
|
||||
onClick = {
|
||||
onSelect(draft)
|
||||
onDismiss()
|
||||
},
|
||||
) { Text(stringResource(R.string.action_done)) }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text(stringResource(R.string.actions_cancel)) }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun QualityDialog(
|
||||
title: String,
|
||||
|
||||
@@ -4,7 +4,9 @@ import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.subtracks.data.model.Source
|
||||
import com.subtracks.data.net.NetworkMode
|
||||
import com.subtracks.data.prefs.DEFAULT_SYNC_INTERVAL_MINUTES
|
||||
import com.subtracks.data.prefs.StreamQuality
|
||||
import com.subtracks.data.prefs.SyncMode
|
||||
import com.subtracks.data.prefs.UserPreferences
|
||||
import com.subtracks.data.repo.SourceRepository
|
||||
import com.subtracks.data.source.DEFAULT_FETCH_CONCURRENCY
|
||||
@@ -48,6 +50,16 @@ class SettingsViewModel(
|
||||
.syncConcurrency()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_FETCH_CONCURRENCY)
|
||||
|
||||
val syncMode: StateFlow<SyncMode> =
|
||||
userPreferences
|
||||
.syncMode()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), SyncMode.WifiOnly)
|
||||
|
||||
val syncIntervalMinutes: StateFlow<Int> =
|
||||
userPreferences
|
||||
.syncIntervalMinutes()
|
||||
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), DEFAULT_SYNC_INTERVAL_MINUTES)
|
||||
|
||||
val scrobbling: StateFlow<Boolean> =
|
||||
userPreferences
|
||||
.scrobbling()
|
||||
@@ -92,6 +104,14 @@ class SettingsViewModel(
|
||||
viewModelScope.launch { userPreferences.setSyncConcurrency(value) }
|
||||
}
|
||||
|
||||
fun setSyncMode(mode: SyncMode) {
|
||||
viewModelScope.launch { userPreferences.setSyncMode(mode) }
|
||||
}
|
||||
|
||||
fun setSyncIntervalMinutes(value: Int) {
|
||||
viewModelScope.launch { userPreferences.setSyncIntervalMinutes(value) }
|
||||
}
|
||||
|
||||
fun setScrobbling(enabled: Boolean) {
|
||||
viewModelScope.launch { userPreferences.setScrobbling(enabled) }
|
||||
}
|
||||
|
||||
@@ -2,6 +2,9 @@
|
||||
<resources xmlns:tools="http://schemas.android.com/tools">
|
||||
<string name="app_name">subtracks</string>
|
||||
<string name="download_channel_description">Progress while songs download</string>
|
||||
<string name="sync_notification_channel">Sync</string>
|
||||
<string name="sync_notification_title">Syncing library</string>
|
||||
<string name="sync_notification_text">Keeping your library up to date</string>
|
||||
|
||||
<string name="actions_cancel">Cancel</string>
|
||||
<string name="actions_delete">Delete</string>
|
||||
@@ -201,6 +204,21 @@
|
||||
<string name="sync_concurrency">Sync concurrency</string>
|
||||
<string name="sync_concurrency_parallel">Parallel server requests while syncing</string>
|
||||
<string name="sync_concurrency_sequential">1 (sequential)</string>
|
||||
<string name="settings_sync_auto_title">Sync automatically</string>
|
||||
<string name="sync_mode_off">Off</string>
|
||||
<string name="sync_mode_wifi">Wi-Fi only</string>
|
||||
<string name="sync_mode_always">Always</string>
|
||||
<string name="sync_mode_prompt">When to sync automatically</string>
|
||||
<string name="sync_interval">Automatic sync interval</string>
|
||||
<string name="sync_interval_prompt">How often to sync automatically</string>
|
||||
<plurals name="sync_interval_minutes">
|
||||
<item quantity="one">%1$d minute</item>
|
||||
<item quantity="other">%1$d minutes</item>
|
||||
</plurals>
|
||||
<plurals name="sync_interval_hours">
|
||||
<item quantity="one">%1$d hour</item>
|
||||
<item quantity="other">%1$d hours</item>
|
||||
</plurals>
|
||||
<string name="download_quality">Download quality</string>
|
||||
<string name="download_over_mobile">Download over mobile data</string>
|
||||
<string name="download_network_wifi_and_mobile">Wi-Fi and mobile</string>
|
||||
|
||||
@@ -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