mirror of
https://github.com/austinried/subtracks.git
synced 2026-11-11 07:29:14 +01:00
Replay pending actions when connectivity is restored
A raw network drop and recovery never flipped the offline flag, so the queue only drained on the next sink op, an offline-mode toggle or a restart. Observe the default network's availability and use it, together with the persisted offline preference, to trigger a replay. This signal only drains pending actions; it never changes offline mode.
This commit is contained in:
@@ -52,3 +52,29 @@ fun networkMode(context: Context): Flow<NetworkMode> {
|
|||||||
awaitClose { if (registered) runCatching { connectivity?.unregisterNetworkCallback(callback) } }
|
awaitClose { if (registered) runCatching { connectivity?.unregisterNetworkCallback(callback) } }
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun networkAvailable(context: Context): Flow<Boolean> {
|
||||||
|
val connectivity = context.applicationContext.getSystemService(ConnectivityManager::class.java)
|
||||||
|
return callbackFlow {
|
||||||
|
fun push() {
|
||||||
|
val capabilities =
|
||||||
|
connectivity?.activeNetwork?.let { connectivity.getNetworkCapabilities(it) }
|
||||||
|
// INTERNET, not VALIDATED: a LAN-only server is reachable without internet validation.
|
||||||
|
trySend(capabilities?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true)
|
||||||
|
}
|
||||||
|
val callback =
|
||||||
|
object : ConnectivityManager.NetworkCallback() {
|
||||||
|
override fun onAvailable(network: Network) = push()
|
||||||
|
|
||||||
|
override fun onLost(network: Network) = push()
|
||||||
|
|
||||||
|
override fun onCapabilitiesChanged(
|
||||||
|
network: Network,
|
||||||
|
capabilities: NetworkCapabilities,
|
||||||
|
) = push()
|
||||||
|
}
|
||||||
|
val registered = runCatching { connectivity?.registerDefaultNetworkCallback(callback) }.isSuccess
|
||||||
|
push()
|
||||||
|
awaitClose { if (registered) runCatching { connectivity?.unregisterNetworkCallback(callback) } }
|
||||||
|
}.distinctUntilChanged()
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import kotlinx.coroutines.CancellationException
|
|||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
@@ -21,20 +23,23 @@ class PendingServerActionSink(
|
|||||||
private val activeSourceId: suspend () -> Long?,
|
private val activeSourceId: suspend () -> Long?,
|
||||||
private val offline: StateFlow<Boolean>,
|
private val offline: StateFlow<Boolean>,
|
||||||
offlinePreferences: Flow<Boolean>,
|
offlinePreferences: Flow<Boolean>,
|
||||||
|
networkAvailable: Flow<Boolean>,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
) : ServerActionSink {
|
) : ServerActionSink {
|
||||||
private val lock = Mutex()
|
private val lock = Mutex()
|
||||||
|
|
||||||
init {
|
init {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
offlinePreferences.collect { isOffline ->
|
combine(offlinePreferences, networkAvailable) { isOffline, online -> !isOffline && online }
|
||||||
// The persisted value is authoritative, unlike the process flag's default at cold
|
.distinctUntilChanged()
|
||||||
// start, so wait for that flag to agree before replaying.
|
.collect { ready ->
|
||||||
if (!isOffline) {
|
// The persisted flag and connectivity are authoritative, unlike the process
|
||||||
offline.first { !it }
|
// flag's default at cold start, so wait for that flag to agree before replaying.
|
||||||
flush()
|
if (ready) {
|
||||||
|
offline.first { !it }
|
||||||
|
flush()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import com.subtracks.data.download.DownloadEngine
|
|||||||
import com.subtracks.data.download.DownloadNotifier
|
import com.subtracks.data.download.DownloadNotifier
|
||||||
import com.subtracks.data.download.OkHttpArtworkFetcher
|
import com.subtracks.data.download.OkHttpArtworkFetcher
|
||||||
import com.subtracks.data.download.SystemDownloadEngine
|
import com.subtracks.data.download.SystemDownloadEngine
|
||||||
|
import com.subtracks.data.net.networkAvailable
|
||||||
import com.subtracks.data.net.networkMode
|
import com.subtracks.data.net.networkMode
|
||||||
import com.subtracks.data.prefs.UserPreferences
|
import com.subtracks.data.prefs.UserPreferences
|
||||||
import com.subtracks.data.prefs.createUserPreferences
|
import com.subtracks.data.prefs.createUserPreferences
|
||||||
@@ -85,6 +86,7 @@ fun appModule(
|
|||||||
activeSourceId = get<SourceRepository>()::activeSourceIdOnce,
|
activeSourceId = get<SourceRepository>()::activeSourceIdOnce,
|
||||||
offline = get<SourceRepository>().offline,
|
offline = get<SourceRepository>().offline,
|
||||||
offlinePreferences = get<UserPreferences>().offlineMode(),
|
offlinePreferences = get<UserPreferences>().offlineMode(),
|
||||||
|
networkAvailable = networkAvailable(context.applicationContext),
|
||||||
scope = get(),
|
scope = get(),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,9 +15,12 @@ import com.subtracks.data.source.StarType
|
|||||||
import com.subtracks.data.source.subsonic.SubsonicException
|
import com.subtracks.data.source.subsonic.SubsonicException
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
|
import kotlinx.coroutines.flow.flowOf
|
||||||
import kotlinx.coroutines.runBlocking
|
import kotlinx.coroutines.runBlocking
|
||||||
|
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||||
import org.junit.After
|
import org.junit.After
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
import org.junit.Assert.assertThrows
|
import org.junit.Assert.assertThrows
|
||||||
@@ -73,6 +76,7 @@ class PendingServerActionSinkTest {
|
|||||||
private lateinit var sink: PendingServerActionSink
|
private lateinit var sink: PendingServerActionSink
|
||||||
private val offline = MutableStateFlow(true)
|
private val offline = MutableStateFlow(true)
|
||||||
private val offlinePreferences = MutableStateFlow(true)
|
private val offlinePreferences = MutableStateFlow(true)
|
||||||
|
private val networkAvailable = MutableStateFlow(true)
|
||||||
private var sourceId = 0L
|
private var sourceId = 0L
|
||||||
|
|
||||||
@Before
|
@Before
|
||||||
@@ -93,6 +97,7 @@ class PendingServerActionSinkTest {
|
|||||||
activeSourceId = { sourceId },
|
activeSourceId = { sourceId },
|
||||||
offline = offline,
|
offline = offline,
|
||||||
offlinePreferences = offlinePreferences,
|
offlinePreferences = offlinePreferences,
|
||||||
|
networkAvailable = networkAvailable,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -131,6 +136,40 @@ class PendingServerActionSinkTest {
|
|||||||
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
|
@Test
|
||||||
|
fun aRestoredConnectionReplaysThroughTheCollector() =
|
||||||
|
runBlocking {
|
||||||
|
offline.value = false
|
||||||
|
networkAvailable.value = false
|
||||||
|
val reconnectScope = CoroutineScope(SupervisorJob() + UnconfinedTestDispatcher())
|
||||||
|
try {
|
||||||
|
val reconnectable =
|
||||||
|
PendingServerActionSink(
|
||||||
|
delegate = delegate,
|
||||||
|
db = db,
|
||||||
|
activeSourceId = { sourceId },
|
||||||
|
offline = offline,
|
||||||
|
offlinePreferences = flowOf(false),
|
||||||
|
networkAvailable = networkAvailable,
|
||||||
|
scope = reconnectScope,
|
||||||
|
)
|
||||||
|
delegate.scrobbleFailure = IOException("down")
|
||||||
|
|
||||||
|
reconnectable.scrobble("s1", 1_000)
|
||||||
|
assertEquals(1, db.pendingActionDao().pending(sourceId).size)
|
||||||
|
|
||||||
|
delegate.scrobbleFailure = null
|
||||||
|
networkAvailable.value = true
|
||||||
|
|
||||||
|
awaitUntil("the queued scrobble to replay") { delegate.calls.isNotEmpty() }
|
||||||
|
assertEquals(listOf(Call.Scrobble("s1", 1_000)), delegate.calls)
|
||||||
|
assertEquals(0, db.pendingActionDao().pending(sourceId).size)
|
||||||
|
} finally {
|
||||||
|
cancelAndJoinBlocking(reconnectScope)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun anOfflineUnstarCoalescesAwayAnEarlierStar() =
|
fun anOfflineUnstarCoalescesAwayAnEarlierStar() =
|
||||||
runBlocking {
|
runBlocking {
|
||||||
|
|||||||
Reference in New Issue
Block a user