refactor: use typed ProfilePictureUrl class instead of URI scheme string

Replace profilepic:// string scheme with a ProfilePictureUrl data class.
Coil routes to the fetcher by type via Fetcher.Factory<ProfilePictureUrl>
instead of parsing a URI scheme string on every load. This avoids string
concatenation at the call site and string parsing in the fetcher/keyer.

https://claude.ai/code/session_01PBJS3HDrurLP3i5n4tMsCv
This commit is contained in:
Claude
2026-04-05 15:22:38 +00:00
parent 3f032710d2
commit 0df4c5e19c
3 changed files with 26 additions and 36 deletions
@@ -24,7 +24,6 @@ import android.graphics.Bitmap
import android.graphics.BitmapFactory import android.graphics.BitmapFactory
import androidx.compose.runtime.Stable import androidx.compose.runtime.Stable
import coil3.ImageLoader import coil3.ImageLoader
import coil3.Uri
import coil3.annotation.ExperimentalCoilApi import coil3.annotation.ExperimentalCoilApi
import coil3.asImage import coil3.asImage
import coil3.decode.DataSource import coil3.decode.DataSource
@@ -39,7 +38,7 @@ import coil3.network.ConnectivityChecker
import coil3.network.NetworkFetcher import coil3.network.NetworkFetcher
import coil3.network.okhttp.asNetworkClient import coil3.network.okhttp.asNetworkClient
import coil3.request.Options import coil3.request.Options
import com.vitorpamplona.amethyst.commons.ui.components.PROFILE_PIC_SCHEME import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Call import okhttp3.Call
@@ -48,7 +47,8 @@ import kotlin.coroutines.cancellation.CancellationException
/** /**
* Coil Fetcher that serves pre-resized profile picture thumbnails from a dedicated disk cache. * Coil Fetcher that serves pre-resized profile picture thumbnails from a dedicated disk cache.
* *
* URLs are wrapped as `profilepic://https://example.com/pic.jpg` by the avatar composables. * Composables pass [ProfilePictureUrl] as the model to AsyncImage. Coil routes to this
* fetcher by type — no string concatenation or scheme parsing needed.
* *
* Flow: * Flow:
* - Thumbnail cache hit: returns the tiny ~5KB JPEG directly. No network, no large decode. * - Thumbnail cache hit: returns the tiny ~5KB JPEG directly. No network, no large decode.
@@ -56,7 +56,7 @@ import kotlin.coroutines.cancellation.CancellationException
* pipeline (zero overhead). In the background, reads the file from Coil's disk cache * pipeline (zero overhead). In the background, reads the file from Coil's disk cache
* after download and generates a thumbnail for next time. * after download and generates a thumbnail for next time.
* *
* The zoomable full-screen dialog uses the raw URL (without profilepic:// prefix), * The zoomable full-screen dialog uses the raw URL string (not wrapped in ProfilePictureUrl),
* which goes through the normal Coil pipeline for full-resolution display. * which goes through the normal Coil pipeline for full-resolution display.
*/ */
@Stable @Stable
@@ -164,39 +164,26 @@ class ProfilePictureFetcher(
return inSampleSize return inSampleSize
} }
companion object {
const val SCHEME = PROFILE_PIC_SCHEME
fun extractOriginalUrl(data: Uri): String {
// profilepic://https://example.com/pic.jpg → https://example.com/pic.jpg
val full = data.toString()
return full.removePrefix("$SCHEME://")
}
}
@OptIn(ExperimentalCoilApi::class) @OptIn(ExperimentalCoilApi::class)
class Factory( class Factory(
private val thumbnailCache: ThumbnailDiskCache, private val thumbnailCache: ThumbnailDiskCache,
private val networkClient: (url: String) -> Call.Factory, private val networkClient: (url: String) -> Call.Factory,
private val backgroundScope: CoroutineScope, private val backgroundScope: CoroutineScope,
) : Fetcher.Factory<Uri> { ) : Fetcher.Factory<ProfilePictureUrl> {
private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker) private val connectivityCheckerLazy = singleParameterLazy(::ConnectivityChecker)
override fun create( override fun create(
data: Uri, data: ProfilePictureUrl,
options: Options, options: Options,
imageLoader: ImageLoader, imageLoader: ImageLoader,
): Fetcher? { ): Fetcher {
if (data.scheme != SCHEME) return null
val originalUrl = extractOriginalUrl(data)
val diskCacheLazy = lazy { imageLoader.diskCache } val diskCacheLazy = lazy { imageLoader.diskCache }
val netFetcher = val netFetcher =
NetworkFetcher( NetworkFetcher(
url = originalUrl, url = data.url,
options = options, options = options,
networkClient = lazy { networkClient(originalUrl).asNetworkClient() }, networkClient = lazy { networkClient(data.url).asNetworkClient() },
diskCache = diskCacheLazy, diskCache = diskCacheLazy,
cacheStrategy = lazy { CacheStrategy.DEFAULT }, cacheStrategy = lazy { CacheStrategy.DEFAULT },
connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) }, connectivityChecker = lazy { connectivityCheckerLazy.get(options.context) },
@@ -204,7 +191,7 @@ class ProfilePictureFetcher(
) )
return ProfilePictureFetcher( return ProfilePictureFetcher(
originalUrl, data.url,
options, options,
thumbnailCache, thumbnailCache,
netFetcher, netFetcher,
@@ -214,15 +201,10 @@ class ProfilePictureFetcher(
} }
} }
object BKeyer : Keyer<Uri> { object BKeyer : Keyer<ProfilePictureUrl> {
override fun key( override fun key(
data: Uri, data: ProfilePictureUrl,
options: Options, options: Options,
): String? = ): String = "profilepic_thumb_${data.url}"
if (data.scheme == SCHEME) {
"profilepic_thumb_${extractOriginalUrl(data)}"
} else {
null
}
} }
} }
@@ -35,7 +35,7 @@ import androidx.compose.ui.graphics.vector.rememberVectorPainter
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.ui.components.PROFILE_PIC_SCHEME import com.vitorpamplona.amethyst.commons.ui.components.ProfilePictureUrl
import com.vitorpamplona.amethyst.ui.theme.isLight import com.vitorpamplona.amethyst.ui.theme.isLight
import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter import com.vitorpamplona.amethyst.ui.theme.onBackgroundColorFilter
@@ -93,7 +93,7 @@ fun RobohashFallbackAsyncImage(
} }
AsyncImage( AsyncImage(
model = "$PROFILE_PIC_SCHEME://$model", model = ProfilePictureUrl(model),
contentDescription = contentDescription, contentDescription = contentDescription,
modifier = modifier, modifier = modifier,
placeholder = painter, placeholder = painter,
@@ -41,7 +41,15 @@ import coil3.compose.AsyncImage
import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash import com.vitorpamplona.amethyst.commons.robohash.CachedRobohash
import com.vitorpamplona.amethyst.commons.ui.theme.isLight import com.vitorpamplona.amethyst.commons.ui.theme.isLight
const val PROFILE_PIC_SCHEME = "profilepic" /**
* Wrapper class for profile picture URLs that signals Coil to use the thumbnail
* disk cache fetcher instead of the normal network pipeline. Passing this as the
* model to AsyncImage avoids string concatenation/parsing and lets Coil route
* by type via Fetcher.Factory<ProfilePictureUrl>.
*/
data class ProfilePictureUrl(
val url: String,
)
/** /**
* Shared avatar component that displays a user's profile picture with Robohash fallback. * Shared avatar component that displays a user's profile picture with Robohash fallback.
@@ -73,9 +81,9 @@ fun UserAvatar(
.clip(shape = CircleShape) .clip(shape = CircleShape)
} }
val imageModel = val imageModel: Any? =
if (pictureUrl != null && useThumbnailCache) { if (pictureUrl != null && useThumbnailCache) {
"$PROFILE_PIC_SCHEME://$pictureUrl" ProfilePictureUrl(pictureUrl)
} else { } else {
pictureUrl pictureUrl
} }