diff --git a/CLAUDE.md b/CLAUDE.md index ae6c9d2..9db6c5b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2608,7 +2608,15 @@ logo. Things to preserve: page's `produceState` and a focus warm and both die when the D-pad moves. - **"Could not tell" is not remembered.** A failed or cancelled probe returns null and is not recorded — a hiccup must not condemn a title to its text heading for the life of the - process. `TitleLogoCacheTest` pins that, the single flight and the eviction. + process. The composing resolver retries once by bypassing and replacing Coil's cache, and + the display request has the same one-retry ceiling; both are explicit attempt counts rather + than state that can loop on recomposition. `TitleLogoCacheTest` pins the recovery, its bound, + the single flight and the eviction. +- **A thin episode route rehydrates from its series.** Returning from an episode page can + reconstruct the Continue Watching selection without its `ParentLogo*` fields, so + `HomeViewModel` reads the matching series' shared detail record before concluding that the + logo is absent. Existing row artwork wins and a response whose series id does not match is + refused; `ContinueWatchingResumeTest` pins both halves. - **One width, `data/LOGO_MAX_WIDTH` (720).** It was four — 800 in the screensaver, 720 on the detail page and in the player, 640 on the home hero, 420 in the TV calendar — and a width is part of the URL, so one show's logo was four cache keys, four fetches and four diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 40ac69d..1cad99f 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -62,7 +62,7 @@ val projectNoticeText = rootProject.file("NOTICE").readText() .replace("https://g.sublogue.com/admin/memby", membySourceUrl) -val defaultVersionName = "0.3.08" +val defaultVersionName = "0.3.09" val membyVersionName: String = (project.findProperty("memby.versionName") as String?) ?.trim() diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt index 18ed7a0..4491049 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/AppRoot.kt @@ -391,10 +391,11 @@ internal fun AppRoot( BackHandler { if (loaded.confirmExitMemby) confirmingExit = true else onCloseSettings() } - key(loaded.userId, loaded.serverUrl) { + val browseIdentity = browseUserIdentity(loaded) + key(browseIdentity) { HomeScreen(settings = loaded, remoteConfig = remoteConfig) } - LaunchedEffect(loaded.userId, loaded.serverUrl) { + LaunchedEffect(browseIdentity) { // Report after the launcher has submitted a frame, not merely when its // composable was entered. StartupTimingMetric can now distinguish the // quick opening surface from the point at which D-pad content is live. diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt index 32eb52e..39aecd6 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeScreen.kt @@ -202,14 +202,12 @@ internal fun HomeScreen( val context = LocalContext.current val scope = rememberCoroutineScope() val factory = remember(repo) { HomeViewModelFactory(repo) } + val browseIdentity = browseUserIdentity(settings) // HomeViewModel owns user-scoped rows, recommendation state, metadata caches and - // in-flight requests. The Activity's default ViewModelStore outlives a Compose key, - // so using it here leaks the previous profile's state across a profile switch. - val profileViewModelOwner = remember( - settings.activeProfileId, - settings.serverUrl, - settings.userId, - ) { ProfileViewModelStoreOwner() } + // in-flight requests. The Activity's default ViewModelStore outlives a Compose key. + // A household viewer changes without changing the Emby profile, so the complete browse + // identity must own this store or the outgoing viewer's rows and requests survive. + val profileViewModelOwner = remember(browseIdentity) { ProfileViewModelStoreOwner() } DisposableEffect(profileViewModelOwner) { onDispose { profileViewModelOwner.clear() } } @@ -1805,20 +1803,15 @@ internal fun HomeScreen( // Selecting a person is the panel's own press now rather than a screen // reached from it, which is the whole point: this is the thing a household // changes nightly. Everything on the launcher belongs to the outgoing - // viewer, so the journey is closed and the rows refreshed rather than left - // standing under a different person's name — the same work the full picker - // does, because it is the same switch. + // viewer, so the journey is closed and the rows replaced rather than left + // standing under a different person's name. Changing the viewer rebuilds + // the shared launcher lifecycle, including its ViewModel and focus graph. onViewerSelected = { viewer -> userSwitcherVisible = false navigationExpanded = false - scope.launch { - delay(16.milliseconds) - runCatching { navigationFocusRequester.requestFocus() } - } scope.launch { homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) repo.switchViewer(viewer) - homeViewModel.refreshAll() } }, onAddViewer = { @@ -1852,27 +1845,29 @@ internal fun HomeScreen( ) } if (showViewerPicker) { - val closeViewerPicker: () -> Unit = { + val closeViewerPicker: (restoreFocus: Boolean) -> Unit = { restoreFocus -> showViewerPicker = false - scope.launch { - delay(16.milliseconds) - runCatching { navigationFocusRequester.requestFocus() } + if (restoreFocus) { + scope.launch { + delay(16.milliseconds) + runCatching { navigationFocusRequester.requestFocus() } + } } } - BackHandler(onBack = closeViewerPicker) + BackHandler { closeViewerPicker(true) } Box(Modifier.fillMaxSize().zIndex(20f).background(MembySurface)) { ViewerPicker( viewers = viewers, activeViewerId = settings.activeViewerId, onViewerSelected = { viewer -> - closeViewerPicker() + // Do not aim focus back into the outgoing viewer's graph while its + // user-keyed launcher is being disposed. + closeViewerPicker(false) scope.launch { // Everything on the launcher belongs to the outgoing viewer, so - // the journey is closed and the rows are refreshed rather than - // left standing under a different person's name. + // the journey is closed before the viewer-keyed launcher is rebuilt. homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) repo.switchViewer(viewer) - homeViewModel.refreshAll() } }, // Both open over the picker rather than instead of it, so Back is one @@ -1899,7 +1894,6 @@ internal fun HomeScreen( val removed = repo.removeViewer(viewer) if (removed && watching) { homeViewModel.endJourneyBeforeProfileSwitch(journeyScreen) - homeViewModel.refreshAll() } refreshViewers() viewerBusyId = null diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt index bd07101..c598ea4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/HomeViewModel.kt @@ -424,10 +424,22 @@ class HomeViewModel(private val repository: EmbyRepository) : ViewModel() { delay(FOCUS_METADATA_DEBOUNCE_MS) if (!item.isSchedule) { val details = loadDetailMetadata(item.id) ?: return@launch - val taggedDetails = focusedItemWithMetadata(item, details) + var taggedDetails = focusedItemWithMetadata(item, details) + // A restored/navigation episode can be thinner than the Continue Watching + // row it came from. If neither that route object nor its item response names + // the series logo, rehydrate the missing image identity from the series' + // shared detail record instead of treating absent fields as "no logo". + if (taggedDetails.isEpisode && !taggedDetails.hasTitleLogoMetadata) { + taggedDetails = taggedDetails.withSeriesTitleLogo( + taggedDetails.seriesId?.let { loadDetailMetadata(it) }, + ) + } if (_focusedItem.value?.id == item.id) { _focusedItem.value = taggedDetails } + if (repository.showTitleLogo) { + warmTitleLogo(repository.logoUrl(taggedDetails)) + } } } detailPrefetchJob = viewModelScope.launch(Dispatchers.IO) { @@ -947,6 +959,29 @@ internal fun focusedItemWithMetadata(item: BaseItem, metadata: BaseItem?): BaseI membyCompatibility = item.membyCompatibility ?: metadata?.membyCompatibility, ) +/** Whether the item carries enough wire metadata to build its canonical logo URL. */ +internal val BaseItem.hasTitleLogoMetadata: Boolean + get() = !imageTags["Logo"].isNullOrBlank() || + (!parentLogoItemId.isNullOrBlank() && !parentLogoImageTag.isNullOrBlank()) + +/** + * Fills an episode's missing parent-logo identity from its fully hydrated series record. + * + * Existing episode metadata always wins. The series is accepted only when its id matches + * the episode's declared SeriesId, so a late response for another focused show can never + * lend this card the wrong artwork. + */ +internal fun BaseItem.withSeriesTitleLogo(series: BaseItem?): BaseItem { + if (hasTitleLogoMetadata || !isEpisode || series == null || series.id != seriesId) return this + val identity = when { + !series.imageTags["Logo"].isNullOrBlank() -> series.id to series.imageTags.getValue("Logo") + !series.parentLogoItemId.isNullOrBlank() && !series.parentLogoImageTag.isNullOrBlank() -> + series.parentLogoItemId to series.parentLogoImageTag + else -> null + } ?: return this + return copy(parentLogoItemId = identity.first, parentLogoImageTag = identity.second) +} + /** * Whether a cached object can stand in for the detail endpoint's response. * diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt b/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt index d599ece..24d5ecb 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/TitleLogo.kt @@ -3,7 +3,10 @@ package com.ponzischeme89.memby.ui import android.content.Context import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.layout.ContentScale @@ -20,6 +23,7 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.async +import kotlinx.coroutines.delay import kotlin.coroutines.cancellation.CancellationException /** @@ -111,10 +115,46 @@ internal object TitleLogoCache { probe(url)?.also { dark -> synchronized(lock) { verdicts[url] = dark } } }.also { fresh -> inFlight[url] = fresh - fresh.invokeOnCompletion { synchronized(lock) { inFlight.remove(url) } } + fresh.invokeOnCompletion { + synchronized(lock) { + // A retry may already have replaced a completed null request. + if (inFlight[url] === fresh) inFlight.remove(url) + } + } } } - return work.await() + val result = work.await() + // A null result is deliberately retryable. Remove it synchronously rather than + // relying only on invokeOnCompletion: a bounded recovery can begin in the same + // coroutine immediately after await(), before that callback has run. + if (result == null) { + synchronized(lock) { + if (inFlight[url] === work) inFlight.remove(url) + } + } + return result + } + + /** + * Resolves a known logo with one bounded recovery after an uncertain image fetch. + * + * [refresh] is false for the ordinary cache-backed attempt and true for recovery, so + * the image layer can bypass a broken local entry and replace it with fresh bytes. A + * failure is still never stored as an artwork verdict, and [attempts] is an explicit + * ceiling rather than a recomposition-driven retry loop. + */ + suspend fun resolveWithRetry( + url: String, + attempts: Int = LOGO_RESOLVE_ATTEMPTS, + retryDelayMillis: Long = LOGO_RETRY_DELAY_MS, + probe: suspend (url: String, refresh: Boolean) -> Boolean?, + ): Boolean? { + require(attempts > 0) { "attempts must be positive" } + repeat(attempts) { attempt -> + resolve(url) { probe(it, attempt > 0) }?.let { return it } + if (attempt + 1 < attempts && retryDelayMillis > 0) delay(retryDelayMillis) + } + return null } /** Test seam only: a verdict cache that outlived one test would decide the next. */ @@ -147,7 +187,9 @@ internal fun useTextTitleForLogo(logoUrl: String?): Boolean { val known = TitleLogoCache.verdict(logoUrl) val isDark by produceState(initialValue = known ?: true, logoUrl, known) { if (known != null) return@produceState - value = TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) } ?: true + value = TitleLogoCache.resolveWithRetry(logoUrl) { url, refresh -> + probeLogoDarkness(context, url, refresh) + } ?: true } return isDark } @@ -166,12 +208,31 @@ internal fun TitleLogoImage( modifier: Modifier = Modifier, alignment: Alignment = Alignment.Center, ) { + val context = LocalContext.current + var recoveryAttempt by remember(logoUrl) { mutableIntStateOf(0) } + val request = remember(context, logoUrl, recoveryAttempt) { + ImageRequest.Builder(context) + .data(logoUrl) + .apply { + if (recoveryAttempt > 0) { + // A known logo whose normal display request failed is a broken/missing + // cache entry, not proof that the artwork does not exist. Bypass both + // reads once and replace them with the successfully fetched bytes. + memoryCachePolicy(CachePolicy.WRITE_ONLY) + diskCachePolicy(CachePolicy.WRITE_ONLY) + } + } + .build() + } AsyncImage( - model = logoUrl, + model = request, contentDescription = contentDescription, contentScale = ContentScale.Fit, alignment = alignment, modifier = modifier, + onError = { + if (recoveryAttempt + 1 < LOGO_DISPLAY_ATTEMPTS) recoveryAttempt++ + }, ) } @@ -199,7 +260,9 @@ internal suspend fun warmTitleLogo(logoUrl: String?) { .build(), ) } - TitleLogoCache.resolve(logoUrl) { probeLogoDarkness(context, it) } + TitleLogoCache.resolveWithRetry(logoUrl) { url, refresh -> + probeLogoDarkness(context, url, refresh) + } } /** @@ -220,12 +283,19 @@ private const val WARM_SIZE_PX = 512 * cache is left on deliberately: the bytes are worth keeping, and they are what the display * request reads instead of going back to the network. */ -private suspend fun probeLogoDarkness(context: Context, url: String): Boolean? = runCatching { +private suspend fun probeLogoDarkness( + context: Context, + url: String, + refresh: Boolean, +): Boolean? = runCatching { val result = context.imageLoader.execute( ImageRequest.Builder(context) .data(url) .allowHardware(false) - .memoryCachePolicy(CachePolicy.DISABLED) + .memoryCachePolicy(if (refresh) CachePolicy.WRITE_ONLY else CachePolicy.DISABLED) + .apply { + if (refresh) diskCachePolicy(CachePolicy.WRITE_ONLY) + } .size(64, 64) .build(), ) as? SuccessResult ?: return@runCatching null @@ -253,3 +323,8 @@ private fun isPredominantlyDarkLogo(bitmap: android.graphics.Bitmap): Boolean { } return opaquePixels < 12 || darkPixels.toFloat() / opaquePixels > 0.82f } + +/** One ordinary request plus one cache-bypassing recovery; neither can loop on recomposition. */ +private const val LOGO_RESOLVE_ATTEMPTS = 2 +private const val LOGO_DISPLAY_ATTEMPTS = 2 +private const val LOGO_RETRY_DELAY_MS = 250L diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt index cb3deae..ca4802e 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/UserSwitcherNavigation.kt @@ -1,11 +1,34 @@ package com.ponzischeme89.memby.ui import com.ponzischeme89.memby.data.EmbyProfile +import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.data.model.MembyViewer import com.ponzischeme89.memby.ui.viewers.viewerIsActive internal enum class UserSwitcherDirection { UP, DOWN } +/** + * The person whose browse state is allowed to share one Compose lifecycle. + * + * A gateway viewer changes without changing the signed-in Emby account, so [Settings.userId] + * alone is not a user identity. Keeping the viewer here makes every user-dependent launcher + * object — rows, scroll positions, focus requesters and ViewModels — leave composition as one + * unit when the person on the sofa changes. + */ +internal data class BrowseUserIdentity( + val profileId: String?, + val serverUrl: String?, + val userId: String?, + val viewerId: String, +) + +internal fun browseUserIdentity(settings: Settings): BrowseUserIdentity = BrowseUserIdentity( + profileId = settings.activeProfileId, + serverUrl = settings.serverUrl, + userId = settings.userId, + viewerId = settings.activeViewerId, +) + /** * The scrolling list at the top of the panel occupies [0, rowCount); the pinned actions * follow it in order. Keeping this arithmetic outside Compose makes remote navigation diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt index 6f5b5d3..7e77974 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaBadges.kt @@ -142,6 +142,18 @@ fun MediaBadge( fontSize: TextUnit = 11.sp, lineHeight: TextUnit = TextUnit.Unspecified, ) { + if (label == "CC") { + Image( + painter = painterResource(R.drawable.icon_cc), + contentDescription = "Closed captions available", + contentScale = ContentScale.Crop, + modifier = modifier + .height(18.dp) + .aspectRatio(1.5f), + ) + return + } + val surroundSoundDescription = when (label) { "5.1" -> "5.1 surround sound" "7.1" -> "7.1 surround sound" diff --git a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt index 9b429ae..63a31d4 100644 --- a/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt +++ b/app/src/main/java/com/ponzischeme89/memby/ui/components/media/MediaCardMetadata.kt @@ -206,6 +206,7 @@ internal fun mediaBadges(item: BaseItem): List { audio?.channels == 8 -> add("7.1") audio?.channels == 6 -> add("5.1") } + if (item.mediaStreams.any { it.type.equals("Subtitle", ignoreCase = true) }) add("CC") }.distinct() } diff --git a/app/src/main/res/drawable-nodpi/icon_cc.png b/app/src/main/res/drawable-nodpi/icon_cc.png new file mode 100644 index 0000000..5182500 Binary files /dev/null and b/app/src/main/res/drawable-nodpi/icon_cc.png differ diff --git a/app/src/main/res/drawable-nodpi/icon_surround_sound.png b/app/src/main/res/drawable-nodpi/icon_surround_sound.png index ab4ea7c..262fcf3 100644 Binary files a/app/src/main/res/drawable-nodpi/icon_surround_sound.png and b/app/src/main/res/drawable-nodpi/icon_surround_sound.png differ diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt index 5d8d613..dd68f1c 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/ContinueWatchingResumeTest.kt @@ -49,6 +49,61 @@ class ContinueWatchingResumeTest { assertEquals("parent-logo-tag", focused.parentLogoImageTag) } + @Test + fun `a thin restored episode rehydrates its logo from the series`() { + val episode = BaseItem( + id = "episode", + name = "The Episode", + type = "Episode", + seriesId = "series", + seriesName = "The Show", + ) + val series = BaseItem( + id = "series", + name = "The Show", + type = "Series", + imageTags = mapOf("Logo" to "series-logo-tag"), + ) + + val rehydrated = episode.withSeriesTitleLogo(series) + + assertTrue(rehydrated.hasTitleLogoMetadata) + assertEquals("series", rehydrated.parentLogoItemId) + assertEquals("series-logo-tag", rehydrated.parentLogoImageTag) + } + + @Test + fun `series rehydration cannot replace richer continue watching artwork`() { + val episode = BaseItem( + id = "episode", + type = "Episode", + seriesId = "series", + parentLogoItemId = "series", + parentLogoImageTag = "row-logo-tag", + ) + val series = BaseItem( + id = "series", + type = "Series", + imageTags = mapOf("Logo" to "detail-logo-tag"), + ) + + val rehydrated = episode.withSeriesTitleLogo(series) + + assertEquals("row-logo-tag", rehydrated.parentLogoImageTag) + } + + @Test + fun `a response for another series cannot lend an episode its logo`() { + val episode = BaseItem(id = "episode", type = "Episode", seriesId = "series") + val otherSeries = BaseItem( + id = "other-series", + type = "Series", + imageTags = mapOf("Logo" to "wrong-logo-tag"), + ) + + assertFalse(episode.withSeriesTitleLogo(otherSeries).hasTitleLogoMetadata) + } + @Test fun `detail metadata keeps the continue watching playhead and resume label`() { val card = BaseItem( diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt index b3f4c82..9df9a3b 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/MediaBadgesTest.kt @@ -43,6 +43,54 @@ class MediaBadgesTest { assertEquals(listOf("7.1"), badgesFor(8)) } + @Test + fun `includes closed captions for movies with a subtitle stream`() { + val item = BaseItem( + id = "captioned-movie", + type = "Movie", + mediaStreams = listOf(MediaStream(type = "Subtitle", language = "eng")), + ) + + assertEquals(listOf("CC"), mediaBadges(item)) + } + + @Test + fun `includes closed captions for episodes with a subtitle stream`() { + val item = BaseItem( + id = "captioned-episode", + type = "Episode", + mediaStreams = listOf(MediaStream(type = "subtitle", language = "eng")), + ) + + assertEquals(listOf("CC"), mediaBadges(item)) + } + + @Test + fun `places closed captions after surround sound when both are available`() { + val item = BaseItem( + id = "captioned-surround-movie", + type = "Movie", + mediaStreams = listOf( + MediaStream(type = "Audio", channels = 6), + MediaStream(type = "Subtitle", language = "eng"), + ), + ) + + assertEquals(listOf("5.1", "CC"), mediaBadges(item)) + } + + @Test + fun `does not infer closed captions from presentation text`() { + val item = BaseItem( + id = "uncaptioned-movie", + name = "A Movie CC", + type = "Movie", + mediaStreams = listOf(MediaStream(type = "Audio", title = "English CC", channels = 2)), + ) + + assertEquals(emptyList(), mediaBadges(item)) + } + /** HDR10+ used to be named in the spec row and collapse to a plain "HDR" badge. */ @Test fun `names HDR10+ rather than collapsing it to HDR`() { diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/TitleLogoCacheTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/TitleLogoCacheTest.kt index df13f3b..6f9e311 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/TitleLogoCacheTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/TitleLogoCacheTest.kt @@ -88,6 +88,42 @@ class TitleLogoCacheTest { assertEquals(false, TitleLogoCache.verdict(url)) } + @Test + fun `an uncertain cache read is retried once with a refresh`() = runBlocking { + val url = "https://gw/v1/images/1/logo?tag=a" + val refreshes = mutableListOf() + + val result = TitleLogoCache.resolveWithRetry( + url = url, + retryDelayMillis = 0, + ) { _, refresh -> + refreshes += refresh + if (refresh) false else null + } + + assertEquals(false, result) + assertEquals(listOf(false, true), refreshes) + assertEquals(false, TitleLogoCache.verdict(url)) + } + + @Test + fun `logo recovery stops at its attempt limit`() = runBlocking { + val probes = AtomicInteger() + + assertNull( + TitleLogoCache.resolveWithRetry( + url = "https://gw/v1/images/1/logo?tag=a", + attempts = 2, + retryDelayMillis = 0, + ) { _, _ -> + probes.incrementAndGet() + null + }, + ) + + assertEquals(2, probes.get()) + } + @Test fun `the cache is bounded and keeps what is still being looked at`() = runBlocking { val kept = "https://gw/v1/images/kept/logo?tag=a" diff --git a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt index cf550ea..7017a61 100644 --- a/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt +++ b/app/src/test/java/com/ponzischeme89/memby/ui/UserSwitcherNavigationTest.kt @@ -1,11 +1,41 @@ package com.ponzischeme89.memby.ui +import com.ponzischeme89.memby.data.Settings import com.ponzischeme89.memby.ui.profiles.profileFocusIndexAfterRemoval import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals import org.junit.Assert.assertNull import org.junit.Test class UserSwitcherNavigationTest { + @Test + fun `browse identity changes when the active household viewer changes`() { + val account = Settings( + serverUrl = "https://memby.example", + userId = "account", + activeViewerId = "", + ) + + assertNotEquals( + browseUserIdentity(account), + browseUserIdentity(account.copy(activeViewerId = "viewer-b")), + ) + } + + @Test + fun `browse identity is stable for non-user preference changes`() { + val before = Settings( + serverUrl = "https://memby.example", + userId = "account", + activeViewerId = "viewer-b", + ) + + assertEquals( + browseUserIdentity(before), + browseUserIdentity(before.copy(homeCardDensity = "compact")), + ) + } + @Test fun `removed profile hands focus to its stable neighbour`() { assertEquals(1, profileFocusIndexAfterRemoval(removedIndex = 1, remainingCount = 3)) diff --git a/icon-5.1.png b/icon-5.1.png new file mode 100644 index 0000000..262fcf3 Binary files /dev/null and b/icon-5.1.png differ diff --git a/icon-cc.png b/icon-cc.png new file mode 100644 index 0000000..5182500 Binary files /dev/null and b/icon-cc.png differ