From 3ed50d699681efb178cb704972787f6595709c6a Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 23:02:15 +0200 Subject: [PATCH 001/119] feat: keep the session and reconnect while the server is down --- .../video/typetype/tv/data/net/ServerWatch.kt | 67 +++++++++++ .../typetype/tv/data/session/ProfileCache.kt | 23 ++++ .../typetype/tv/data/session/SessionCheck.kt | 30 +++++ .../tv/data/session/SessionManager.kt | 68 +++++++++-- .../typetype/tv/data/session/SessionState.kt | 5 +- .../java/video/typetype/tv/ui/AppEntry.kt | 2 + .../video/typetype/tv/ui/auth/LoginScreen.kt | 2 +- .../typetype/tv/ui/auth/ReconnectScreen.kt | 55 +++++++++ app/src/main/res/values/strings.xml | 8 +- .../tv/data/session/SessionRestoreTest.kt | 107 ++++++++++++++++++ 10 files changed, 353 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/video/typetype/tv/data/net/ServerWatch.kt create mode 100644 app/src/main/java/video/typetype/tv/data/session/ProfileCache.kt create mode 100644 app/src/main/java/video/typetype/tv/data/session/SessionCheck.kt create mode 100644 app/src/main/java/video/typetype/tv/ui/auth/ReconnectScreen.kt create mode 100644 app/src/test/java/video/typetype/tv/data/session/SessionRestoreTest.kt diff --git a/app/src/main/java/video/typetype/tv/data/net/ServerWatch.kt b/app/src/main/java/video/typetype/tv/data/net/ServerWatch.kt new file mode 100644 index 0000000..4295517 --- /dev/null +++ b/app/src/main/java/video/typetype/tv/data/net/ServerWatch.kt @@ -0,0 +1,67 @@ +package video.typetype.tv.data.net + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.selects.select +import kotlinx.coroutines.withContext +import okhttp3.HttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.util.concurrent.TimeUnit + +class ServerWatch( + private val reachable: suspend () -> Boolean, + private val slowAfterMillis: Long = SLOW_AFTER_MILLIS, + private val probeEveryMillis: Long = PROBE_EVERY_MILLIS, +) { + suspend fun watch(block: suspend () -> T): T { + val context = currentCoroutineContext() + val jobs = SupervisorJob(context[Job]) + val scope = CoroutineScope(context + jobs) + try { + val work = scope.async { block() } + val silence = scope.async { + delay(slowAfterMillis) + while (reachable()) delay(probeEveryMillis) + } + return select { + work.onAwait { it } + silence.onAwait { + throw ApiException(ApiFailure(FailureCategory.Network, null, UNREACHABLE, "The server is not answering", null)) + } + } + } finally { + jobs.cancel() + } + } + + companion object { + const val UNREACHABLE = "server_unreachable" + private const val SLOW_AFTER_MILLIS = 5_000L + private const val PROBE_EVERY_MILLIS = 5_000L + private const val PROBE_TIMEOUT_SECONDS = 4L + + fun health(apiBase: HttpUrl, client: OkHttpClient): ServerWatch { + val url = apiBase.resolve("health") ?: apiBase + val probe = client.newBuilder() + .callTimeout(PROBE_TIMEOUT_SECONDS, TimeUnit.SECONDS) + .retryOnConnectionFailure(false) + .build() + return ServerWatch({ + withContext(Dispatchers.IO) { + try { + probe.newCall(Request.Builder().url(url).build()).execute().use { it.isSuccessful } + } catch (_: IOException) { + false + } + } + }) + } + } +} diff --git a/app/src/main/java/video/typetype/tv/data/session/ProfileCache.kt b/app/src/main/java/video/typetype/tv/data/session/ProfileCache.kt new file mode 100644 index 0000000..3af4e00 --- /dev/null +++ b/app/src/main/java/video/typetype/tv/data/session/ProfileCache.kt @@ -0,0 +1,23 @@ +package video.typetype.tv.data.session + +import video.typetype.tv.data.dto.UserProfileDto +import video.typetype.tv.data.net.ApiJson +import video.typetype.tv.data.net.SecretStore + +class ProfileCache(private val store: SecretStore) { + fun get(): UserProfileDto? = store.get(KEY)?.let { raw -> + runCatching { ApiJson.instance.decodeFromString(UserProfileDto.serializer(), raw) }.getOrNull() + } + + fun put(profile: UserProfileDto) { + store.put(KEY, ApiJson.instance.encodeToString(UserProfileDto.serializer(), profile)) + } + + fun clear() { + store.remove(KEY) + } + + private companion object { + const val KEY = "profile" + } +} diff --git a/app/src/main/java/video/typetype/tv/data/session/SessionCheck.kt b/app/src/main/java/video/typetype/tv/data/session/SessionCheck.kt new file mode 100644 index 0000000..50a7eed --- /dev/null +++ b/app/src/main/java/video/typetype/tv/data/session/SessionCheck.kt @@ -0,0 +1,30 @@ +package video.typetype.tv.data.session + +import video.typetype.tv.data.dto.UserProfileDto +import video.typetype.tv.data.net.ApiException +import video.typetype.tv.data.net.FailureCategory +import java.io.IOException + +sealed interface SessionCheck { + data class Valid(val profile: UserProfileDto) : SessionCheck + + data object Rejected : SessionCheck + + data object Unknown : SessionCheck + + companion object { + suspend fun of(backend: Backend): SessionCheck = try { + Valid(backend.watch.watch { backend.api.me() }) + } catch (failure: ApiException) { + if (failure.failure.category == FailureCategory.Unauthorized) Rejected else Unknown + } catch (_: IOException) { + Unknown + } + + fun retryDelayMillis(attempt: Int): Long = + (FIRST_RETRY_MILLIS shl (attempt - 1).coerceIn(0, 4)).coerceAtMost(MAX_RETRY_MILLIS) + + private const val FIRST_RETRY_MILLIS = 2_000L + private const val MAX_RETRY_MILLIS = 30_000L + } +} diff --git a/app/src/main/java/video/typetype/tv/data/session/SessionManager.kt b/app/src/main/java/video/typetype/tv/data/session/SessionManager.kt index e137ffa..031ef47 100644 --- a/app/src/main/java/video/typetype/tv/data/session/SessionManager.kt +++ b/app/src/main/java/video/typetype/tv/data/session/SessionManager.kt @@ -19,10 +19,10 @@ import video.typetype.tv.data.net.AccessTokens import video.typetype.tv.data.net.ApiClient import video.typetype.tv.data.net.ApiException import video.typetype.tv.data.net.ApiJson -import video.typetype.tv.data.net.FailureCategory import video.typetype.tv.data.net.HttpClients import video.typetype.tv.data.net.RefreshCookieJar import video.typetype.tv.data.net.SecretStore +import video.typetype.tv.data.net.ServerWatch import video.typetype.tv.data.net.tls.CertificateInfo import video.typetype.tv.data.net.tls.CertificateProbe import video.typetype.tv.data.net.tls.PinTrust @@ -30,7 +30,9 @@ import video.typetype.tv.data.net.tls.TlsPin import video.typetype.tv.data.net.TokenRefresher import java.io.IOException import java.util.concurrent.atomic.AtomicReference +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.Dispatchers import java.util.concurrent.atomic.AtomicBoolean @@ -42,6 +44,8 @@ class SessionManager(private val store: SecretStore) : AccessTokens, TokenRefres private val token = AtomicReference(null) private val tokenLoaded = AtomicBoolean(false) private val refreshLock = Any() + private val profiles = ProfileCache(store) + private val retryNow = Channel(Channel.CONFLATED) private val mutableState = MutableStateFlow(SessionState.Starting) private var backend: Backend? = null @@ -70,18 +74,53 @@ class SessionManager(private val store: SecretStore) : AccessTokens, TokenRefres mutableState.value = SessionState.SignedOut(built) return } - try { - mutableState.value = SessionState.SignedIn(built, built.api.me()) - } catch (failure: ApiException) { - val reason = if (failure.failure.category == FailureCategory.Unauthorized) { - SignOutReason.Expired - } else { - SignOutReason.Unreachable + val cached = profiles.get() + if (cached == null) { + reconnect(built) + return + } + mutableState.value = SessionState.SignedIn(built, cached) + val check = SessionCheck.of(built) + if ((state.value as? SessionState.SignedIn)?.backend !== built) return + when (check) { + is SessionCheck.Valid -> { + profiles.put(check.profile) + mutableState.value = SessionState.SignedIn(built, check.profile) } - mutableState.value = SessionState.SignedOut(built, reason) + SessionCheck.Rejected -> expire() + SessionCheck.Unknown -> Unit } } + private suspend fun reconnect(built: Backend) { + var attempt = 0 + while (backend === built && token.get() != null) { + mutableState.value = SessionState.Reconnecting(built, attempt) + val check = SessionCheck.of(built) + if (backend !== built) return + when (check) { + is SessionCheck.Valid -> { + profiles.put(check.profile) + mutableState.value = SessionState.SignedIn(built, check.profile) + return + } + SessionCheck.Rejected -> { + expire() + return + } + SessionCheck.Unknown -> { + attempt++ + mutableState.value = SessionState.Reconnecting(built, attempt) + withTimeoutOrNull(SessionCheck.retryDelayMillis(attempt)) { retryNow.receive() } + } + } + } + } + + fun retryConnection() { + retryNow.trySend(Unit) + } + suspend fun configure(origin: String): DiscoveryResult { val result = discovery.discover(origin) if (result is DiscoveryResult.Compatible) { @@ -152,15 +191,17 @@ class SessionManager(private val store: SecretStore) : AccessTokens, TokenRefres .url(apiBase.resolve("auth/refresh") ?: return null) .post("{}".toRequestBody(JSON)) .build() + var rejected = false val fresh = try { plainHttp.newCall(request).execute().use { response -> + rejected = response.code == 401 || response.code == 403 if (!response.isSuccessful) return@use null ApiJson.instance.decodeFromString(SessionResponse.serializer(), response.body.string()).accessToken } } catch (_: IOException) { null } - if (fresh != null) storeToken(fresh) else expire() + if (fresh != null) storeToken(fresh) else if (rejected) expire() fresh } @@ -168,7 +209,9 @@ class SessionManager(private val store: SecretStore) : AccessTokens, TokenRefres val current = backend ?: error("Instance not configured") val fresh = obtain(current) storeToken(fresh) - mutableState.value = SessionState.SignedIn(current, current.api.me()) + val profile = current.api.me() + profiles.put(profile) + mutableState.value = SessionState.SignedIn(current, profile) } private fun build(apiBase: HttpUrl, instance: InstanceDto): Backend { @@ -182,6 +225,7 @@ class SessionManager(private val store: SecretStore) : AccessTokens, TokenRefres presence = PresenceApi(ApiClient(apiBase, authed)), sabr = SabrApi(ApiClient(apiBase, authed)), mediaHttp = HttpClients.media(apiBase.host, this, this, pinTrust), + watch = ServerWatch.health(apiBase, plainHttp), ) backend = built return built @@ -199,12 +243,14 @@ class SessionManager(private val store: SecretStore) : AccessTokens, TokenRefres private fun expire() { token.set(null) store.remove(KEY_ACCESS) + profiles.clear() backend?.let { mutableState.value = SessionState.SignedOut(it, SignOutReason.Expired) } } private fun clearCredentials() { token.set(null) store.remove(KEY_ACCESS) + profiles.clear() cookieJar.clear() } diff --git a/app/src/main/java/video/typetype/tv/data/session/SessionState.kt b/app/src/main/java/video/typetype/tv/data/session/SessionState.kt index 93db74e..4e41f49 100644 --- a/app/src/main/java/video/typetype/tv/data/session/SessionState.kt +++ b/app/src/main/java/video/typetype/tv/data/session/SessionState.kt @@ -7,6 +7,7 @@ import video.typetype.tv.data.api.SabrApi import video.typetype.tv.data.api.TypeTypeApi import video.typetype.tv.data.dto.InstanceDto import video.typetype.tv.data.dto.UserProfileDto +import video.typetype.tv.data.net.ServerWatch import okhttp3.HttpUrl import okhttp3.OkHttpClient @@ -19,6 +20,7 @@ class Backend( val presence: PresenceApi, val sabr: SabrApi, val mediaHttp: OkHttpClient, + val watch: ServerWatch, ) sealed interface SessionState { @@ -26,6 +28,8 @@ sealed interface SessionState { data object Unconfigured : SessionState + data class Reconnecting(val backend: Backend, val failedAttempts: Int) : SessionState + data class SignedOut(val backend: Backend, val notice: SignOutReason? = null) : SessionState data class SignedIn(val backend: Backend, val profile: UserProfileDto) : SessionState @@ -33,5 +37,4 @@ sealed interface SessionState { enum class SignOutReason { Expired, - Unreachable, } diff --git a/app/src/main/java/video/typetype/tv/ui/AppEntry.kt b/app/src/main/java/video/typetype/tv/ui/AppEntry.kt index f4775a7..c0376e7 100644 --- a/app/src/main/java/video/typetype/tv/ui/AppEntry.kt +++ b/app/src/main/java/video/typetype/tv/ui/AppEntry.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.launch import video.typetype.tv.AppContainer import video.typetype.tv.data.session.SessionState import video.typetype.tv.ui.auth.LoginScreen +import video.typetype.tv.ui.auth.ReconnectScreen import video.typetype.tv.ui.auth.SetupScreen import video.typetype.tv.ui.components.InkBackground @@ -32,6 +33,7 @@ fun AppEntry(container: AppContainer) { when (val current = state) { SessionState.Starting -> InkBackground() SessionState.Unconfigured -> SetupScreen(session) + is SessionState.Reconnecting -> ReconnectScreen(session, current.backend.apiBase.host, current.failedAttempts) is SessionState.SignedOut -> LoginScreen(current.backend, session, current.notice) is SessionState.SignedIn -> { val repository = remember(current.backend, current.profile.id) { diff --git a/app/src/main/java/video/typetype/tv/ui/auth/LoginScreen.kt b/app/src/main/java/video/typetype/tv/ui/auth/LoginScreen.kt index 7e05660..80f436a 100644 --- a/app/src/main/java/video/typetype/tv/ui/auth/LoginScreen.kt +++ b/app/src/main/java/video/typetype/tv/ui/auth/LoginScreen.kt @@ -49,7 +49,7 @@ fun LoginScreen( var registering by rememberSaveable { mutableStateOf(false) } var busy by remember { mutableStateOf(false) } var message by remember { - mutableStateOf(notice?.let { UiMessage(if (it == SignOutReason.Expired) R.string.login_notice_expired else R.string.login_notice_unreachable) }) + mutableStateOf(notice?.let { UiMessage(R.string.login_notice_expired) }) } val scope = rememberCoroutineScope() val focus = LocalFocusManager.current diff --git a/app/src/main/java/video/typetype/tv/ui/auth/ReconnectScreen.kt b/app/src/main/java/video/typetype/tv/ui/auth/ReconnectScreen.kt new file mode 100644 index 0000000..8a983fe --- /dev/null +++ b/app/src/main/java/video/typetype/tv/ui/auth/ReconnectScreen.kt @@ -0,0 +1,55 @@ +package video.typetype.tv.ui.auth + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.height +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.rounded.Replay +import androidx.compose.material.icons.rounded.SwapHoriz +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch +import video.typetype.tv.R +import video.typetype.tv.data.session.SessionManager +import video.typetype.tv.ui.components.PillButton +import video.typetype.tv.ui.util.requestFocusWhenReady + +@Composable +fun ReconnectScreen(session: SessionManager, host: String, failedAttempts: Int, modifier: Modifier = Modifier) { + val scope = rememberCoroutineScope() + val retry = remember { FocusRequester() } + LaunchedEffect(Unit) { retry.requestFocusWhenReady() } + AuthScaffold( + title = stringResource(R.string.reconnect_title), + subtitle = host, + modifier = modifier, + ) { + Column { + MessageText(UiMessage(if (failedAttempts == 0) R.string.reconnect_checking else R.string.reconnect_waiting)) + Spacer(Modifier.height(18.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + PillButton( + label = stringResource(R.string.state_retry), + icon = Icons.Rounded.Replay, + onClick = session::retryConnection, + emphasized = true, + modifier = Modifier.focusRequester(retry), + ) + PillButton( + label = stringResource(R.string.settings_change_instance), + icon = Icons.Rounded.SwapHoriz, + onClick = { scope.launch { session.changeInstance() } }, + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dce1302..726509c 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -142,12 +142,18 @@ Something went wrong. Try again. Fill in every field first. Your session expired. Sign in again. - The server could not be reached. Sign in again when it is back. + Connecting to your server + Checking your session. + The server is not answering. The app keeps trying and continues as soon as it is back. Request id: %1$s Try again This video could not be played The change could not be saved. Check the connection and try again. This content could not be loaded + + Trying again in %1$d second + Trying again in %1$d seconds + The server returned an unexpected answer. Auto Skip automatically diff --git a/app/src/test/java/video/typetype/tv/data/session/SessionRestoreTest.kt b/app/src/test/java/video/typetype/tv/data/session/SessionRestoreTest.kt new file mode 100644 index 0000000..78499e0 --- /dev/null +++ b/app/src/test/java/video/typetype/tv/data/session/SessionRestoreTest.kt @@ -0,0 +1,107 @@ +package video.typetype.tv.data.session + +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import okhttp3.mockwebserver.Dispatcher +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import video.typetype.tv.data.dto.InstanceDto +import video.typetype.tv.data.net.ApiJson +import video.typetype.tv.data.net.InMemorySecretStore + +class SessionRestoreTest { + private lateinit var server: MockWebServer + private val store = InMemorySecretStore() + private val responses = mutableMapOf MockResponse>() + + @Before + fun start() { + server = MockWebServer().apply { + dispatcher = object : Dispatcher() { + override fun dispatch(request: RecordedRequest): MockResponse { + val path = request.path.orEmpty().substringAfter("/api/").substringBefore('?') + return responses[path]?.invoke() ?: MockResponse().setResponseCode(404) + } + } + start() + } + store.put("api_base", server.url("/api/").toString()) + store.put("instance", ApiJson.instance.encodeToString(InstanceDto.serializer(), InstanceDto(name = "Test"))) + store.put("access_token", "token-1") + } + + @After + fun stop() { + server.shutdown() + } + + private fun profile(name: String) = MockResponse().setBody("""{"id":"user-1","publicUsername":"$name"}""") + + @Test + fun aCachedProfileOpensTheAppWhileTheServerIsDown() = runBlocking { + store.put("profile", """{"id":"user-1","publicUsername":"Cached"}""") + responses["auth/me"] = { MockResponse().setResponseCode(522) } + val session = SessionManager(store) + session.restore() + val state = session.state.value as SessionState.SignedIn + assertEquals("Cached", state.profile.publicUsername) + assertEquals("token-1", store.get("access_token")) + } + + @Test + fun aCachedProfileIsRefreshedWhenTheServerAnswers() = runBlocking { + store.put("profile", """{"id":"user-1","publicUsername":"Cached"}""") + responses["auth/me"] = { profile("Fresh") } + val session = SessionManager(store) + session.restore() + assertEquals("Fresh", (session.state.value as SessionState.SignedIn).profile.publicUsername) + assertTrue(store.get("profile").orEmpty().contains("Fresh")) + } + + @Test + fun withoutACachedProfileTheAppWaitsForTheServerThenContinues() = runBlocking { + var up = false + responses["auth/me"] = { if (up) profile("Back") else MockResponse().setResponseCode(503) } + val session = SessionManager(store) + val restoring = async { session.restore() } + withTimeout(30_000) { session.state.first { it is SessionState.Reconnecting && it.failedAttempts > 0 } } + up = true + session.retryConnection() + withTimeout(30_000) { restoring.await() } + assertEquals("Back", (session.state.value as SessionState.SignedIn).profile.publicUsername) + } + + @Test + fun aSessionTheServerRefusesGoesBackToSignIn() = runBlocking { + store.put("profile", """{"id":"user-1"}""") + responses["auth/me"] = { MockResponse().setResponseCode(401) } + responses["auth/refresh"] = { MockResponse().setResponseCode(401) } + val session = SessionManager(store) + session.restore() + assertEquals(SignOutReason.Expired, (session.state.value as SessionState.SignedOut).notice) + assertEquals(null, store.get("access_token")) + assertEquals(null, store.get("profile")) + } + + @Test + fun aRefreshThatCannotReachTheServerKeepsTheSession() { + store.put("profile", """{"id":"user-1"}""") + val session = SessionManager(store) + runBlocking { + responses["auth/me"] = { profile("Ok") } + session.restore() + } + server.shutdown() + assertEquals(null, session.refreshBlocking("token-1")) + assertEquals("token-1", store.get("access_token")) + assertTrue(session.state.value is SessionState.SignedIn) + } +} From fa69ca2bd5d3c5994135f968110bc768e08ffb50 Mon Sep 17 00:00:00 2001 From: Priveetee Date: Wed, 21 Oct 2026 23:02:15 +0200 Subject: [PATCH 002/119] feat: retry screens on their own when the server is silent --- .../java/video/typetype/tv/AppContainer.kt | 1 + .../tv/data/remote/RemoteContentRepository.kt | 20 ++++---- .../video/typetype/tv/ui/auth/AuthMessages.kt | 2 +- .../tv/ui/components/ScreenFailure.kt | 22 +++++++++ .../video/typetype/tv/ui/util/LoadFailures.kt | 10 ++-- .../typetype/tv/data/net/ServerWatchTest.kt | 46 +++++++++++++++++++ .../video/typetype/tv/ui/LoadFailuresTest.kt | 37 +++++++++++++++ 7 files changed, 126 insertions(+), 12 deletions(-) create mode 100644 app/src/test/java/video/typetype/tv/data/net/ServerWatchTest.kt create mode 100644 app/src/test/java/video/typetype/tv/ui/LoadFailuresTest.kt diff --git a/app/src/main/java/video/typetype/tv/AppContainer.kt b/app/src/main/java/video/typetype/tv/AppContainer.kt index e55f750..606c8e8 100644 --- a/app/src/main/java/video/typetype/tv/AppContainer.kt +++ b/app/src/main/java/video/typetype/tv/AppContainer.kt @@ -53,6 +53,7 @@ class AppContainer(context: Context) { device = deviceProfile.profile(), enabled = !profile.id.startsWith("guest"), ).also { it.report(PresenceReport.Online) }, + watch = backend.watch, ) @OptIn(UnstableApi::class) diff --git a/app/src/main/java/video/typetype/tv/data/remote/RemoteContentRepository.kt b/app/src/main/java/video/typetype/tv/data/remote/RemoteContentRepository.kt index a7ba038..562964c 100644 --- a/app/src/main/java/video/typetype/tv/data/remote/RemoteContentRepository.kt +++ b/app/src/main/java/video/typetype/tv/data/remote/RemoteContentRepository.kt @@ -28,6 +28,7 @@ import video.typetype.tv.data.api.TypeTypeApi import video.typetype.tv.data.dto.BlockChannelRequest import video.typetype.tv.data.dto.StreamDto import video.typetype.tv.data.net.ApiException +import video.typetype.tv.data.net.ServerWatch class RemoteContentRepository( private val api: TypeTypeApi, @@ -36,6 +37,7 @@ class RemoteContentRepository( private val settingsSource: RemoteSettings, private val texts: TextProvider, private val presence: RemotePresence, + private val watch: ServerWatch, private val clock: () -> Long = System::currentTimeMillis, ) : ContentRepository { private val streams = TtlCache(STREAM_TTL_MILLIS, STREAM_ENTRIES, clock) @@ -51,13 +53,13 @@ class RemoteContentRepository( override suspend fun home(): HomeContent { homeCache.fresh(HOME_KEY)?.let { return it } return try { - homeLoader.load().also { homeCache.put(HOME_KEY, it) } + watch.watch { homeLoader.load() }.also { homeCache.put(HOME_KEY, it) } } catch (failure: ApiException) { homeCache.stale(HOME_KEY) ?: throw failure } } - override suspend fun video(id: String): Video? = branding.apply(listOf(stream(id).toVideo(id, clock()))).single() + override suspend fun video(id: String): Video? = watch.watch { branding.apply(listOf(stream(id).toVideo(id, clock()))).single() } override suspend fun related(id: String): List