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:
+13
-31
@@ -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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -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,
|
||||||
|
|||||||
+11
-3
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user