From 34ea983a5117d1a2720124bca782a8e1814b9d95 Mon Sep 17 00:00:00 2001 From: justinzeus Date: Fri, 6 Nov 2026 01:25:47 +0100 Subject: [PATCH] Scope artwork credentials and preserve library recovery --- .../tv/ConductarrAudienceRecoveryTest.kt | 70 ++++++++++++++ .../wolfskin/tv/ConductarrProjectionTest.kt | 2 +- .../wolfskin/tv/FakeConductarr.kt | 4 + .../wolfskin/tv/WolfskinArtworkTest.kt | 91 +++++++++++++++++++ .../wolfskin/tv/ConductarrProvider.kt | 10 +- .../justinvisser/wolfskin/tv/WolfskinApp.kt | 15 ++- .../wolfskin/tv/WolfskinArtwork.kt | 36 ++++++++ docker/README.md | 18 +++- .../wolfskin/design/WolfskinImages.kt | 28 +++++- 9 files changed, 263 insertions(+), 11 deletions(-) create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt create mode 100644 app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt create mode 100644 app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt new file mode 100644 index 00000000..00f970b1 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrAudienceRecoveryTest.kt @@ -0,0 +1,70 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import androidx.lifecycle.viewModelScope +import androidx.test.core.app.ApplicationProvider +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.data.model.JellyfinUser +import io.mockk.mockk +import java.util.UUID +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.cancel +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.resetMain +import kotlinx.coroutines.test.setMain +import org.jellyfin.sdk.model.api.CollectionType +import org.junit.After +import org.junit.Assert.* +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.conductarr.MemoryCacheStore +import org.justinvisser.wolfskin.tv.read.LibraryReadFacade +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config + +/** Conductarr metadata failure cannot revoke libraries granted by Jellyfin. */ +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +class ConductarrAudienceRecoveryTest { + @Before fun immediateMain() { Dispatchers.setMain(Dispatchers.Unconfined) } + @After fun restoreMain() { Dispatchers.resetMain() } + + @Test fun operatorSuccessWithFailedOrEmptyAudiencesKeepsTheNameRuleAndForegroundCanHeal() = runBlocking { + val context = ApplicationProvider.getApplicationContext() + ConductarrPreference.initialize(context) + ConductarrPreference.updateFixtures(false) + for (empty in listOf(false, true)) FakeConductarr().use { fake -> + fake.audiencesStatus = if (empty) 200 else 503 + fake.emptyAudiences = empty + ConductarrSession(MemoryCacheStore()).use { session -> + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", fake.url, null) + val user = JellyfinUser(id = UUID.randomUUID(), name = "Shared", serverId = server.id, accessToken = SHARED_TOKEN) + session.select("${server.id}:${user.id}", SHARED_TOKEN, fake.url) + session.authenticate() + assertTrue(session.identity.value!!.signedIn) + val client = session.identity.value!!.client + val libraries = LibraryReadFacade(mockk()) + val model = WolfskinShellViewModel(context, WolfskinSession(), mockk(), mockk(), mockk(), mockk(relaxed = true), + libraries, WolfskinSeeAllLists(), session, mockk(relaxed = true)) + try { + requireNotNull(model.onForeground(server, user)).join() + assertNull(libraries.audiences) + assertEquals("movies", libraries.destination("Movies", CollectionType.MOVIES)) + assertEquals("shows", libraries.destination("Shows", CollectionType.TVSHOWS)) + assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) + if (!empty) { + fake.audiencesStatus = 200 + requireNotNull(model.onForeground(server, user)).join() + assertEquals(listOf("Shared Anime"), libraries.audiences!!.single().libraries) + assertEquals("anime", libraries.destination("Shared Anime", CollectionType.TVSHOWS)) + assertSame(client, session.identity.value!!.client) + } + } finally { model.viewModelScope.cancel() } + } + } + } +} diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt index 2bcafe31..b5be3c2b 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/ConductarrProjectionTest.kt @@ -15,7 +15,7 @@ class ConductarrProjectionTest { ConductarrClient(fake.url, SHARED_TOKEN, MemoryCacheStore()).use { client -> val provider = ConductarrProvider(client) assertEquals("Shared", provider.profiles()!!.profiles!!.first().name) - assertEquals(listOf("Shared Anime"), provider.audiences().single().libraries) + assertEquals(listOf("Shared Anime"), provider.audiences()!!.single().libraries) val snapshot = provider.discover("shared").last() assertEquals(DiscoverStatus.Ready, snapshot.status) assertEquals("https://image.tmdb.org/t/p/w780/moon.jpg", snapshot.picks.single().poster) diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt index 6170f043..b682b34c 100644 --- a/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/FakeConductarr.kt @@ -22,6 +22,8 @@ internal class FakeConductarr : Closeable { @Volatile var existingRequests = false @Volatile var notModified = false @Volatile var ratingsStatus = 200 + @Volatile var audiencesStatus = 200 + @Volatile var emptyAudiences = false @Volatile var seasonTwoScore = 8.1 @Volatile var localArt = false private val imageBytes = java.util.Base64.getDecoder().decode( @@ -61,9 +63,11 @@ internal class FakeConductarr : Closeable { else -> return response("", 404) } if (key == "ratings" && ratingsStatus != 200) return response("", ratingsStatus) + if (key == "audiences" && audiencesStatus != 200) return response("", audiencesStatus) if (notModified && request.headers["If-None-Match"] != null) return response("", 304) if (key == "requests" && !existingRequests) return response("{\"requests\":[]}") var body = wire.getValue(key).jsonObject + if (key == "audiences" && emptyAudiences) body = JsonObject(body + ("audiences" to JsonArray(emptyList()))) if (key == "operator" && request.headers["Authorization"]?.contains(MINDEL_TOKEN) == true) { body = JsonObject(body + mapOf("name" to JsonPrimitive("Mindel"), "requester" to JsonPrimitive("mindel"), "subject" to JsonPrimitive("jellyfin:mindel"), "watch_user" to JsonPrimitive("mindel"))) } diff --git a/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt new file mode 100644 index 00000000..2e248969 --- /dev/null +++ b/app/src/test/java/org/justinvisser/wolfskin/tv/WolfskinArtworkTest.kt @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.app.Application +import android.graphics.Bitmap +import androidx.activity.ComponentActivity +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import coil3.request.SuccessResult +import coil3.imageLoader +import coil3.request.CachePolicy +import coil3.request.ImageRequest +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.data.model.JellyfinServer +import com.github.damontecres.wholphin.ui.CoilConfig +import io.mockk.every +import io.mockk.mockk +import java.io.ByteArrayOutputStream +import java.util.UUID +import java.util.concurrent.CopyOnWriteArrayList +import mockwebserver3.MockResponse +import mockwebserver3.MockWebServer +import okhttp3.OkHttpClient +import okio.Buffer +import org.junit.Assert.* +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.justinvisser.wolfskin.design.WolfskinImages +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import org.robolectric.annotation.GraphicsMode + +/** The shipped loader decodes both hosts, but only Jellyfin receives the injected credential. */ +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34], application = Application::class) +@GraphicsMode(GraphicsMode.Mode.NATIVE) +class WolfskinArtworkTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test fun inheritedAuthenticatedFetcherCannotSendTheJellyfinTokenToAnotherArtworkHost() { + MockWebServer().use { jellyfin -> + MockWebServer().use { artwork -> + jellyfin.start(); artwork.start() + val context = rule.activity + val credential = "MediaBrowser Client=\"Wolfskin test\", Token=\"$SHARED_TOKEN\"" + val authenticated = OkHttpClient.Builder().addInterceptor { chain -> + chain.proceed(chain.request().newBuilder().addHeader("Authorization", credential).build()) + }.build() + val server = JellyfinServer(UUID.randomUUID(), "Jellyfin", jellyfin.url("/").toString(), null) + val repository = mockk { every { currentServer } returns server } + val network = WolfskinArtwork(authenticated, repository) + val bytes = ByteArrayOutputStream().also { + Bitmap.createBitmap(2, 2, Bitmap.Config.ARGB_8888).compress(Bitmap.CompressFormat.PNG, 100, it) + }.toByteArray() + jellyfin.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) + artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) + artwork.enqueue(MockResponse.Builder().body(Buffer().write(bytes)).build()) + val urls = listOf(jellyfin.url("/jellyfin.png"), + artwork.url("/public.png").newBuilder().host("127.0.0.1").build(), + artwork.url("/another-port.png")) + val results = CopyOnWriteArrayList() + try { + rule.setContent { + CoilConfig(1024 * 1024L, authenticated, debugLogging = false, enableCache = false) + LaunchedEffect(network) { + // Force the upstream authenticated parent to exist before configuring Wolfskin. + context.applicationContext.imageLoader + network.configure(context) + val loader = WolfskinImages.loader(context) + for (url in urls) results.add(loader.execute(ImageRequest.Builder(context).data(url.toString()) + .memoryCachePolicy(CachePolicy.DISABLED).diskCachePolicy(CachePolicy.DISABLED).build())) + } + } + rule.waitUntil(30_000) { + org.robolectric.Shadows.shadowOf(android.os.Looper.getMainLooper()).idle() + results.size == urls.size + } + results.forEach { result -> assertEquals(2, (result as SuccessResult).image.width) } + assertEquals(credential, jellyfin.takeRequest().headers["Authorization"]) + assertNull(artwork.takeRequest().headers["Authorization"]) + assertNull(artwork.takeRequest().headers["Authorization"]) + } finally { + WolfskinImages.loader(context).shutdown() + authenticated.dispatcher.executorService.shutdown() + authenticated.connectionPool.evictAll() + } + } + } + } +} diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt index 922b5b4e..06caea9c 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/ConductarrProvider.kt @@ -116,9 +116,13 @@ class ConductarrProvider(val client: ConductarrClient) : ConductarrTitleProvider profileNames.value = it.profiles.orEmpty() } } - suspend fun audiences(): List { - libraryAudiences = SnapshotReads(client, ReadTrigger.IF_STALE).read(TvRoutes.audiences())?.audiences.orEmpty() - return libraryAudiences + /** Unavailable or empty metadata cannot replace Jellyfin's usable name-based classification. */ + suspend fun audiences(): List? { + val reads = SnapshotReads(client, ReadTrigger.IF_STALE) + val observed = reads.read(TvRoutes.audiences())?.audiences?.takeIf { it.isNotEmpty() } + if (reads.failed || observed == null) return null + libraryAudiences = observed + return observed } fun holders(title: Title): Set = title.discoverTitle(libraryAudiences).heldBy diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt index f1c28006..781f55dd 100644 --- a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinApp.kt @@ -75,7 +75,9 @@ class WolfskinShellViewModel @Inject constructor( val libraries: LibraryReadFacade, val seeAll: WolfskinSeeAllLists, val conductarr: ConductarrSession, + artwork: WolfskinArtwork, ) : ViewModel() { + init { artwork.configure(context) } val backdropFlow: StateFlow = backdrop.backdropFlow private val requestsSnapshot by lazy { readShellRequests(context) } var libraryDestinations by mutableStateOf>(emptyMap()) @@ -119,18 +121,26 @@ class WolfskinShellViewModel @Inject constructor( val profiles = identity.provider.profiles()?.profiles.orEmpty() val audiences = identity.provider.audiences() if (conductarr.identity.value?.client !== identity.client || conductarr.identity.value?.signedIn != true) return - libraries.audiences = audiences.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } + audiences?.filter { it.owner == identity.operator?.requester || identity.operator?.watchUser in it.users.orEmpty() } + ?.takeIf { it.isNotEmpty() }?.let { libraries.audiences = it } val updated = session.state?.people.orEmpty().map { person -> val profile = profiles.firstOrNull { it.key == person.key || it.name.equals(person.name, ignoreCase = true) } val current = person.jellyfinUserId == user.id.toString() person.copy(key = if (current) identity.operator!!.requester else profile?.key ?: person.key, name = if (current) identity.operator!!.name else profile?.name ?: person.name, - hasAnime = audiences.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) }) + hasAnime = audiences?.any { it.isAnime && (it.owner == (profile?.key ?: person.key) || person.key in it.users.orEmpty()) } ?: person.hasAnime) } session.state?.updatePeople(updated) signedInPerson = updated.firstOrNull { it.jellyfinUserId == user.id.toString() }?.key ?: signedInPerson } + /** Foreground recovery retains the current identity and any usable audience mapping. */ + fun onForeground(server: JellyfinServer, user: JellyfinUser): kotlinx.coroutines.Job? { + if (ConductarrPreference.fixtures || conductarr.identity.value?.person != "${server.id}:${user.id}") return null + identityJob?.cancel() + return viewModelScope.launch { loadHousehold(user) }.also { identityJob = it } + } + suspend fun retryConductarr(server: JellyfinServer, user: JellyfinUser) { conductarr.select("${server.id}:${user.id}", user.accessToken, ConductarrPreference.baseUrl, retry = true) loadHousehold(user) @@ -222,6 +232,7 @@ fun WolfskinApp( ) { val identity by viewModel.conductarr.identity.collectAsStateWithLifecycle() LaunchedEffect(server.id, user.id, user.accessToken, ConductarrPreference.baseUrl, ConductarrPreference.fixtures) { viewModel.load(server, user) } + androidx.lifecycle.compose.LifecycleEventEffect(androidx.lifecycle.Lifecycle.Event.ON_RESUME) { viewModel.onForeground(server, user) } LaunchedEffect(identity?.signedIn, identity?.person) { if (!ConductarrPreference.fixtures) { if (identity?.signedIn != true) viewModel.session.state?.requests?.update(org.justinvisser.wolfskin.design.ComingInSnapshot()) diff --git a/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt new file mode 100644 index 00000000..e546a09b --- /dev/null +++ b/app/src/wolfskin/java/org/justinvisser/wolfskin/tv/WolfskinArtwork.kt @@ -0,0 +1,36 @@ +// SPDX-License-Identifier: GPL-2.0-only +package org.justinvisser.wolfskin.tv + +import android.content.Context +import coil3.ComponentRegistry +import coil3.network.ConnectivityChecker +import coil3.network.okhttp.OkHttpNetworkFetcherFactory +import com.github.damontecres.wholphin.data.ServerRepository +import com.github.damontecres.wholphin.services.hilt.AuthOkHttpClient +import javax.inject.Inject +import javax.inject.Singleton +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.OkHttpClient +import org.justinvisser.wolfskin.design.WolfskinImages + +/** Strip credentials after upstream auth injection, including each redirect's network request. */ +@OptIn(coil3.annotation.ExperimentalCoilApi::class) +@Singleton +class WolfskinArtwork @Inject constructor( + @param:AuthOkHttpClient client: OkHttpClient, + repository: ServerRepository, +) { + private val artworkClient = client.newBuilder().addNetworkInterceptor { chain -> + val request = chain.request() + val jellyfin = repository.currentServer?.url?.toHttpUrlOrNull() + val isJellyfin = jellyfin != null && request.url.scheme == jellyfin.scheme && + request.url.host == jellyfin.host && request.url.port == jellyfin.port + chain.proceed(if (isJellyfin) request else request.newBuilder().removeHeader("Authorization").build()) + }.build() + private val configure: ComponentRegistry.Builder.() -> Unit = { + add(OkHttpNetworkFetcherFactory(callFactory = { artworkClient }, connectivityChecker = { ConnectivityChecker { true } })) + } + + /** Install host-scoped networking before any Wolfskin artwork is rendered. */ + fun configure(context: Context) = WolfskinImages.configureNetwork(context, configure) +} diff --git a/docker/README.md b/docker/README.md index ffaa984a..b1ea4087 100644 --- a/docker/README.md +++ b/docker/README.md @@ -466,6 +466,13 @@ Request recipients include authorized Conductarr profiles without saved Jellyfin logins; that does not create selectable TV logins. Late name reads preserve the open chooser's selected recipient and footer focus. +Artwork uses a fresh bounded Wolfskin loader, not the upstream loader's +authenticated fetcher. The app's network interceptor removes Authorization from +every non-Jellyfin artwork origin (scheme, host and port), including redirects; +current Jellyfin artwork still authenticates. A failed or empty library-audience response keeps +the existing mapping or Jellyfin name rule. Returning to the foreground retries +household metadata without rebuilding the selected identity. + | Screen seam | Production supplier | | --- | --- | | People and nav | Operator, profiles, library audiences; saved Jellyfin users remain the only selectable logins | @@ -485,6 +492,10 @@ server tests use representative wire JSON, never a live Conductarr or Jellyfin. isolation, 401/retry, saved offline data, actor-safe requests, ratings timing, title playback permissions/episode votes and native real-provider surfaces. The existing fixture screen tests remain in the normal `make test` gate. +`WolfskinArtworkTest` exercises the actual Coil loader against two HTTP hosts +and a same-host/different-port origin with an authenticated upstream parent, +and verifies successful decoding with credentials only on Jellyfin. `ConductarrAudienceRecoveryTest` covers +operator success plus failed/empty audiences, name-rule retention and foreground healing. Not done here: deploying the dedicated hostname or widening server authority; hardware installation/playback verification; releases, signing or version changes. @@ -499,13 +510,16 @@ Jellyfin login. No token needs to be copied, displayed or entered into Wolfskin. 1. In Advanced settings, set the sandbox URL with fixtures **off**. Select Shared, then another saved person; verify their operator/requester and independent views. + Its hostname must be in Conductarr's TV allowed-host list; arbitrary hosts or + IP addresses are refused even with the correct token. 2. Open Home, Discover, a service, Hidden picks and Requests. Open a title, cast member and filmography credit; check real facts, related titles, episode votes, stage/actor labels, card dwell ratings and continuous See all loading. 3. Make the sandbox return 503 after a successful read. Reopen: saved content and Saved time remain, requests cannot submit, and Try again performs another read. -4. Return 401, then restore authentication. Verify ratings/request actions disappear; - Try again restores data without changing Jellyfin login or copying a token. +4. Return 401, then restore authentication. Verify ratings/request actions disappear + and this person's saved Conductarr data is cleared. Try again restores data + without changing Jellyfin login or copying a token. 5. Have the sandbox accept a Shared-on-behalf request but lose its POST response. Use Check again: the receipt says Shared, and the sandbox records one POST. 6. Restore the blank/default URL when the dedicated TV hostname and server's diff --git a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt index d89254f9..4e0822bf 100644 --- a/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt +++ b/wolfskin-design/src/main/java/org/justinvisser/wolfskin/design/WolfskinImages.kt @@ -1,11 +1,11 @@ package org.justinvisser.wolfskin.design import android.content.Context +import coil3.ComponentRegistry import coil3.ImageLoader import coil3.disk.DiskCache import coil3.intercept.Interceptor import coil3.disk.directory -import coil3.imageLoader import coil3.request.crossfade import coil3.memory.MemoryCache import coil3.request.ImageRequest @@ -18,21 +18,43 @@ import kotlinx.coroutines.sync.withPermit object WolfskinImages { private var application: Context? = null private var loader: ImageLoader? = null + private var networkConfiguration: (ComponentRegistry.Builder.() -> Unit)? = null private const val MEBIBYTE = 1024L * 1024L private const val IMAGE_READS = 4 private val readPermits = Semaphore(IMAGE_READS) + /** The app supplies authenticated networking without exposing its server/session to design code. */ + @Synchronized + fun configureNetwork(context: Context, configure: ComponentRegistry.Builder.() -> Unit) { + val owner = context.applicationContext + if (application === owner && networkConfiguration === configure) return + loader?.shutdown() + loader = null + application = owner + networkConfiguration = configure + } + @Synchronized fun loader(context: Context): ImageLoader { val owner = context.applicationContext if (application !== owner) { loader?.shutdown() + loader = null application = owner + networkConfiguration = null + } + if (loader == null) { val reads = Dispatchers.IO.limitedParallelism(IMAGE_READS) - loader = owner.imageLoader.newBuilder() + // Parent components may attach a Jellyfin token to every HTTP host. + val builder = ImageLoader.Builder(owner) + loader = builder .memoryCache { MemoryCache.Builder().maxSizeBytes((WolfskinTokens.Image.memoryCacheMB * MEBIBYTE).toLong()).build() } .diskCache { DiskCache.Builder().directory(owner.cacheDir.resolve("wolfskin-images")).maxSizeBytes((WolfskinTokens.Image.diskCacheMB * MEBIBYTE).toLong()).build() } - .components { add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) } + .components { + // Coil replaces this registry; install networking and bounds together. + networkConfiguration?.invoke(this) + add(Interceptor { chain -> readPermits.withPermit { chain.proceed() } }) + } .fetcherCoroutineContext(reads).decoderCoroutineContext(reads).crossfade(false).build() } return loader!!