From e52430d99652d97348388cd58afa8458e99b73d8 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 13 Mar 2026 03:40:23 +0100 Subject: [PATCH 1/6] feat: add coil with pubky image fetcher Co-Authored-By: Claude Opus 4.6 --- app/build.gradle.kts | 2 + .../main/java/to/bitkit/data/PubkyFetcher.kt | 48 +++++++++++++++++++ app/src/main/java/to/bitkit/di/ImageModule.kt | 39 +++++++++++++++ gradle/libs.versions.toml | 2 + 4 files changed, 91 insertions(+) create mode 100644 app/src/main/java/to/bitkit/data/PubkyFetcher.kt create mode 100644 app/src/main/java/to/bitkit/di/ImageModule.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a257882a8..12763aa04 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -267,6 +267,8 @@ dependencies { implementation(libs.charts) implementation(libs.haze) implementation(libs.haze.materials) + // Image Loading + implementation(libs.coil.compose) // Compose Navigation implementation(libs.navigation.compose) androidTestImplementation(libs.navigation.testing) diff --git a/app/src/main/java/to/bitkit/data/PubkyFetcher.kt b/app/src/main/java/to/bitkit/data/PubkyFetcher.kt new file mode 100644 index 000000000..dd6c6f2fc --- /dev/null +++ b/app/src/main/java/to/bitkit/data/PubkyFetcher.kt @@ -0,0 +1,48 @@ +package to.bitkit.data + +import coil3.ImageLoader +import coil3.decode.DataSource +import coil3.decode.ImageSource +import coil3.fetch.FetchResult +import coil3.fetch.Fetcher +import coil3.fetch.SourceFetchResult +import coil3.request.Options +import okio.Buffer +import org.json.JSONObject +import to.bitkit.services.PubkyService +import to.bitkit.utils.Logger + +private const val TAG = "PubkyFetcher" +private const val PUBKY_SCHEME = "pubky://" + +class PubkyFetcher( + private val uri: String, + private val options: Options, + private val pubkyService: PubkyService, +) : Fetcher { + + override suspend fun fetch(): FetchResult { + val data = pubkyService.fetchFile(uri) + val blobData = resolveImageData(data) + val source = ImageSource(Buffer().apply { write(blobData) }, options.fileSystem) + return SourceFetchResult(source, null, dataSource = DataSource.NETWORK) + } + + private suspend fun resolveImageData(data: ByteArray): ByteArray = runCatching { + val json = JSONObject(String(data)) + val src = json.optString("src", "") + if (src.isNotEmpty() && src.startsWith(PUBKY_SCHEME)) { + Logger.debug("File descriptor found, fetching blob from '$src'", context = TAG) + pubkyService.fetchFile(src) + } else { + data + } + }.getOrDefault(data) + + class Factory(private val pubkyService: PubkyService) : Fetcher.Factory { + override fun create(data: String, options: Options, imageLoader: ImageLoader): Fetcher? { + if (!data.startsWith(PUBKY_SCHEME)) return null + return PubkyFetcher(data, options, pubkyService) + } + } +} diff --git a/app/src/main/java/to/bitkit/di/ImageModule.kt b/app/src/main/java/to/bitkit/di/ImageModule.kt new file mode 100644 index 000000000..46ae0dfe6 --- /dev/null +++ b/app/src/main/java/to/bitkit/di/ImageModule.kt @@ -0,0 +1,39 @@ +package to.bitkit.di + +import android.content.Context +import coil3.ImageLoader +import coil3.disk.DiskCache +import coil3.disk.directory +import coil3.memory.MemoryCache +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import to.bitkit.data.PubkyFetcher +import to.bitkit.services.PubkyService +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object ImageModule { + + @Provides + @Singleton + fun provideImageLoader( + @ApplicationContext context: Context, + pubkyService: PubkyService, + ): ImageLoader = ImageLoader.Builder(context) + .components { add(PubkyFetcher.Factory(pubkyService)) } + .memoryCache { + MemoryCache.Builder() + .maxSizePercent(context, percent = 0.15) + .build() + } + .diskCache { + DiskCache.Builder() + .directory(context.cacheDir.resolve("pubky-images")) + .build() + } + .build() +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62858901e..68c8cc9c0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,7 @@ [versions] agp = "8.13.2" camera = "1.5.2" +coil = "3.2.0" detekt = "1.23.8" hilt = "2.57.2" hiltAndroidx = "1.3.0" @@ -88,6 +89,7 @@ work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11. zxing = { module = "com.google.zxing:core", version = "3.5.4" } lottie = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" } charts = { module = "io.github.ehsannarmani:compose-charts", version = "0.2.0" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } From b0383b4e3cb439e3c210c4620c321fe7c628de15 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 13 Mar 2026 03:40:55 +0100 Subject: [PATCH 2/6] refactor: migrate pubky images to coil Co-Authored-By: Claude Opus 4.6 --- app/src/main/java/to/bitkit/App.kt | 6 + .../java/to/bitkit/data/PubkyImageCache.kt | 58 -------- .../{PubkyFetcher.kt => PubkyImageFetcher.kt} | 14 +- app/src/main/java/to/bitkit/di/ImageModule.kt | 4 +- .../java/to/bitkit/repositories/PubkyRepo.kt | 40 ++---- .../to/bitkit/ui/components/PubkyImage.kt | 127 ++++-------------- .../to/bitkit/data/PubkyImageFetcherTest.kt | 88 ++++++++++++ .../to/bitkit/repositories/PubkyRepoTest.kt | 52 +------ docs/pubky.md | 34 ++--- 9 files changed, 165 insertions(+), 258 deletions(-) delete mode 100644 app/src/main/java/to/bitkit/data/PubkyImageCache.kt rename app/src/main/java/to/bitkit/data/{PubkyFetcher.kt => PubkyImageFetcher.kt} (78%) create mode 100644 app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt diff --git a/app/src/main/java/to/bitkit/App.kt b/app/src/main/java/to/bitkit/App.kt index 27b3a7c17..d9f4a7d91 100644 --- a/app/src/main/java/to/bitkit/App.kt +++ b/app/src/main/java/to/bitkit/App.kt @@ -7,6 +7,8 @@ import android.app.Application.ActivityLifecycleCallbacks import android.os.Bundle import androidx.hilt.work.HiltWorkerFactory import androidx.work.Configuration +import coil3.ImageLoader +import coil3.SingletonImageLoader import dagger.hilt.android.HiltAndroidApp import to.bitkit.env.Env import javax.inject.Inject @@ -16,6 +18,9 @@ internal open class App : Application(), Configuration.Provider { @Inject lateinit var workerFactory: HiltWorkerFactory + @Inject + lateinit var imageLoader: ImageLoader + override val workManagerConfiguration get() = Configuration.Builder() .setWorkerFactory(workerFactory) @@ -23,6 +28,7 @@ internal open class App : Application(), Configuration.Provider { override fun onCreate() { super.onCreate() + SingletonImageLoader.setSafe { imageLoader } currentActivity = CurrentActivity().also { registerActivityLifecycleCallbacks(it) } Env.initAppStoragePath(filesDir.absolutePath) } diff --git a/app/src/main/java/to/bitkit/data/PubkyImageCache.kt b/app/src/main/java/to/bitkit/data/PubkyImageCache.kt deleted file mode 100644 index 0f8fdc9c7..000000000 --- a/app/src/main/java/to/bitkit/data/PubkyImageCache.kt +++ /dev/null @@ -1,58 +0,0 @@ -package to.bitkit.data - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.BitmapFactory -import dagger.hilt.android.qualifiers.ApplicationContext -import to.bitkit.ext.toHex -import java.io.File -import java.security.MessageDigest -import java.util.concurrent.ConcurrentHashMap -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class PubkyImageCache @Inject constructor( - @ApplicationContext context: Context, -) { - private val memoryCache = ConcurrentHashMap() - private val diskDir: File = File(context.cacheDir, "pubky-images").also { it.mkdirs() } - - fun memoryImage(uri: String): Bitmap? = memoryCache[uri] - - fun image(uri: String): Bitmap? { - memoryCache[uri]?.let { return it } - - val file = diskPath(uri) - if (file.exists()) { - val bitmap = BitmapFactory.decodeFile(file.absolutePath) ?: return null - memoryCache[uri] = bitmap - return bitmap - } - return null - } - - fun decodeAndStore(data: ByteArray, uri: String): Result = runCatching { - val bitmap = BitmapFactory.decodeByteArray(data, 0, data.size) - ?: error("Could not decode image blob (${data.size} bytes)") - store(bitmap, data, uri) - bitmap - } - - fun store(bitmap: Bitmap, data: ByteArray, uri: String): Result = runCatching { - memoryCache[uri] = bitmap - diskPath(uri).writeBytes(data) - } - - fun clear(): Result = runCatching { - memoryCache.clear() - diskDir.deleteRecursively() - diskDir.mkdirs() - } - - private fun diskPath(uri: String): File { - val digest = MessageDigest.getInstance("SHA-256") - val hash = digest.digest(uri.toByteArray()).toHex() - return File(diskDir, hash) - } -} diff --git a/app/src/main/java/to/bitkit/data/PubkyFetcher.kt b/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt similarity index 78% rename from app/src/main/java/to/bitkit/data/PubkyFetcher.kt rename to app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt index dd6c6f2fc..b3880a52f 100644 --- a/app/src/main/java/to/bitkit/data/PubkyFetcher.kt +++ b/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt @@ -1,6 +1,7 @@ package to.bitkit.data import coil3.ImageLoader +import coil3.Uri import coil3.decode.DataSource import coil3.decode.ImageSource import coil3.fetch.FetchResult @@ -12,10 +13,10 @@ import org.json.JSONObject import to.bitkit.services.PubkyService import to.bitkit.utils.Logger -private const val TAG = "PubkyFetcher" +private const val TAG = "PubkyImageFetcher" private const val PUBKY_SCHEME = "pubky://" -class PubkyFetcher( +class PubkyImageFetcher( private val uri: String, private val options: Options, private val pubkyService: PubkyService, @@ -39,10 +40,11 @@ class PubkyFetcher( } }.getOrDefault(data) - class Factory(private val pubkyService: PubkyService) : Fetcher.Factory { - override fun create(data: String, options: Options, imageLoader: ImageLoader): Fetcher? { - if (!data.startsWith(PUBKY_SCHEME)) return null - return PubkyFetcher(data, options, pubkyService) + class Factory(private val pubkyService: PubkyService) : Fetcher.Factory { + override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { + val uri = data.toString() + if (!uri.startsWith(PUBKY_SCHEME)) return null + return PubkyImageFetcher(uri, options, pubkyService) } } } diff --git a/app/src/main/java/to/bitkit/di/ImageModule.kt b/app/src/main/java/to/bitkit/di/ImageModule.kt index 46ae0dfe6..1d1d1d164 100644 --- a/app/src/main/java/to/bitkit/di/ImageModule.kt +++ b/app/src/main/java/to/bitkit/di/ImageModule.kt @@ -10,7 +10,7 @@ import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent -import to.bitkit.data.PubkyFetcher +import to.bitkit.data.PubkyImageFetcher import to.bitkit.services.PubkyService import javax.inject.Singleton @@ -24,7 +24,7 @@ object ImageModule { @ApplicationContext context: Context, pubkyService: PubkyService, ): ImageLoader = ImageLoader.Builder(context) - .components { add(PubkyFetcher.Factory(pubkyService)) } + .components { add(PubkyImageFetcher.Factory(pubkyService)) } .memoryCache { MemoryCache.Builder() .maxSizePercent(context, percent = 0.15) diff --git a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt index 0d79f6434..43565ef54 100644 --- a/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt +++ b/app/src/main/java/to/bitkit/repositories/PubkyRepo.kt @@ -1,6 +1,6 @@ package to.bitkit.repositories -import android.graphics.Bitmap +import coil3.ImageLoader import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob @@ -18,8 +18,6 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.withContext -import org.json.JSONObject -import to.bitkit.data.PubkyImageCache import to.bitkit.data.PubkyStore import to.bitkit.data.keychain.Keychain import to.bitkit.di.IoDispatcher @@ -36,7 +34,7 @@ class PubkyRepo @Inject constructor( @IoDispatcher private val ioDispatcher: CoroutineDispatcher, private val pubkyService: PubkyService, private val keychain: Keychain, - private val imageCache: PubkyImageCache, + private val imageLoader: ImageLoader, private val pubkyStore: PubkyStore, ) { companion object { @@ -251,7 +249,7 @@ class PubkyRepo @Inject constructor( withContext(ioDispatcher) { pubkyService.forceSignOut() } }.also { runCatching { withContext(ioDispatcher) { keychain.delete(Keychain.Key.PAYKIT_SESSION.name) } } - runCatching { withContext(ioDispatcher) { imageCache.clear() } } + evictPubkyImages() runCatching { withContext(ioDispatcher) { pubkyStore.reset() } } _publicKey.update { null } _profile.update { null } @@ -259,29 +257,17 @@ class PubkyRepo @Inject constructor( _authState.update { PubkyAuthState.Idle } } - fun cachedImage(uri: String): Bitmap? = imageCache.memoryImage(uri) - - suspend fun fetchImage(uri: String): Result = runCatching { - withContext(ioDispatcher) { - imageCache.image(uri)?.let { return@withContext it } - - val data = pubkyService.fetchFile(uri) - val blobData = resolveImageData(data) - imageCache.decodeAndStore(blobData, uri).getOrThrow() + private fun evictPubkyImages() { + imageLoader.memoryCache?.let { cache -> + cache.keys.filter { it.key.startsWith(PUBKY_SCHEME) }.forEach { cache.remove(it) } + } + val imageUris = buildList { + _profile.value?.imageUrl?.let { add(it) } + addAll(_contacts.value.mapNotNull { it.imageUrl }) + } + imageLoader.diskCache?.let { cache -> + imageUris.forEach { cache.remove(it) } } - } - - private suspend fun resolveImageData(data: ByteArray): ByteArray { - return runCatching { - val json = JSONObject(String(data)) - val src = json.optString("src", "") - if (src.isNotEmpty() && src.startsWith(PUBKY_SCHEME)) { - Logger.debug("File descriptor found, fetching blob from: '$src'", context = TAG) - pubkyService.fetchFile(src) - } else { - data - } - }.getOrDefault(data) } private suspend fun cacheMetadata(profile: PubkyProfile) { diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt index 5cd5f2893..eeac776fb 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt @@ -1,7 +1,5 @@ package to.bitkit.ui.components -import android.graphics.Bitmap -import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -9,70 +7,16 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel -import androidx.lifecycle.ViewModel -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.lifecycle.viewModelScope -import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.launch +import coil3.compose.SubcomposeAsyncImage import to.bitkit.R -import to.bitkit.repositories.PubkyRepo import to.bitkit.ui.theme.Colors -import to.bitkit.utils.Logger -import javax.inject.Inject - -private const val TAG = "PubkyImage" - -@HiltViewModel -class PubkyImageViewModel @Inject constructor( - private val pubkyRepo: PubkyRepo, -) : ViewModel() { - - private val _images = MutableStateFlow>(emptyMap()) - val images = _images.asStateFlow() - - fun loadImage(uri: String) { - val current = _images.value[uri] - if (current is PubkyImageState.Loaded || current is PubkyImageState.Loading) return - - val cached = pubkyRepo.cachedImage(uri) - if (cached != null) { - _images.update { it + (uri to PubkyImageState.Loaded(cached)) } - return - } - - _images.update { it + (uri to PubkyImageState.Loading) } - viewModelScope.launch { - pubkyRepo.fetchImage(uri) - .onSuccess { bitmap -> - _images.update { it + (uri to PubkyImageState.Loaded(bitmap)) } - } - .onFailure { - Logger.error("Failed to load pubky image '$uri'", it, context = TAG) - _images.update { it + (uri to PubkyImageState.Failed) } - } - } - } -} - -sealed interface PubkyImageState { - data object Loading : PubkyImageState - data class Loaded(val bitmap: Bitmap) : PubkyImageState - data object Failed : PubkyImageState -} @Composable fun PubkyImage( @@ -80,51 +24,36 @@ fun PubkyImage( size: Dp, modifier: Modifier = Modifier, ) { - val viewModel: PubkyImageViewModel = hiltViewModel() - val images by viewModel.images.collectAsStateWithLifecycle() - val state = images[uri] - - LaunchedEffect(uri) { - viewModel.loadImage(uri) - } - - Box( - contentAlignment = Alignment.Center, - modifier = modifier - .size(size) - .clip(CircleShape) - ) { - when (state) { - is PubkyImageState.Loaded -> { - Image( - bitmap = state.bitmap.asImageBitmap(), - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize() - ) - } - is PubkyImageState.Failed -> { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .matchParentSize() - .background(Colors.Gray5, CircleShape) - ) { - Icon( - painter = painterResource(R.drawable.ic_user_square), - contentDescription = null, - tint = Colors.White32, - modifier = Modifier.size(size / 2) - ) - } - } - else -> { + SubcomposeAsyncImage( + model = uri, + contentDescription = null, + contentScale = ContentScale.Crop, + loading = { + Box(contentAlignment = Alignment.Center) { CircularProgressIndicator( strokeWidth = 2.dp, color = Colors.White32, - modifier = Modifier.size(size / 3) + modifier = Modifier.size(size / 3), ) } - } - } + }, + error = { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .matchParentSize() + .background(Colors.Gray5, CircleShape), + ) { + Icon( + painter = painterResource(R.drawable.ic_user_square), + contentDescription = null, + tint = Colors.White32, + modifier = Modifier.size(size / 2), + ) + } + }, + modifier = modifier + .size(size) + .clip(CircleShape), + ) } diff --git a/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt b/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt new file mode 100644 index 000000000..3c6b697f5 --- /dev/null +++ b/app/src/test/java/to/bitkit/data/PubkyImageFetcherTest.kt @@ -0,0 +1,88 @@ +package to.bitkit.data + +import androidx.test.core.app.ApplicationProvider +import coil3.request.Options +import coil3.size.Size +import coil3.toUri +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import to.bitkit.services.PubkyService +import to.bitkit.test.BaseUnitTest +import kotlin.test.assertNotNull +import kotlin.test.assertNull + +@RunWith(RobolectricTestRunner::class) +@Config(sdk = [34]) +class PubkyImageFetcherTest : BaseUnitTest() { + + private val pubkyService = mock() + private val factory = PubkyImageFetcher.Factory(pubkyService) + private val options = Options(ApplicationProvider.getApplicationContext(), size = Size.ORIGINAL) + + @Test + fun `factory should return fetcher for pubky uris`() = test { + val fetcher = factory.create("pubky://image_uri".toUri(), options, mock()) + + assertNotNull(fetcher) + } + + @Test + fun `factory should return null for non-pubky uris`() = test { + val fetcher = factory.create("https://example.com/image.png".toUri(), options, mock()) + + assertNull(fetcher) + } + + @Test + fun `fetch should return raw data when response is not json`() = test { + val imageBytes = byteArrayOf(0x89.toByte(), 0x50, 0x4E, 0x47) // PNG header + whenever(pubkyService.fetchFile("pubky://image")).thenReturn(imageBytes) + val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService) + + val result = fetcher.fetch() + + assertNotNull(result) + verify(pubkyService).fetchFile("pubky://image") + } + + @Test + fun `fetch should follow json file descriptor with pubky src`() = test { + val descriptor = """{"src": "pubky://blob_uri"}""".toByteArray() + val blobBytes = byteArrayOf(0xFF.toByte(), 0xD8.toByte()) // JPEG header + whenever(pubkyService.fetchFile("pubky://image")).thenReturn(descriptor) + whenever(pubkyService.fetchFile("pubky://blob_uri")).thenReturn(blobBytes) + val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService) + + fetcher.fetch() + + verify(pubkyService).fetchFile("pubky://blob_uri") + } + + @Test + fun `fetch should not follow json src with non-pubky scheme`() = test { + val descriptor = """{"src": "https://example.com/image.png"}""".toByteArray() + whenever(pubkyService.fetchFile("pubky://image")).thenReturn(descriptor) + val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService) + + fetcher.fetch() + + verify(pubkyService, never()).fetchFile("https://example.com/image.png") + } + + @Test + fun `fetch should not follow json without src field`() = test { + val json = """{"name": "test"}""".toByteArray() + whenever(pubkyService.fetchFile("pubky://image")).thenReturn(json) + val fetcher = PubkyImageFetcher("pubky://image", options, pubkyService) + + val result = fetcher.fetch() + + assertNotNull(result) + } +} diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index 11e7679f5..fbce7d730 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1,7 +1,7 @@ package to.bitkit.repositories -import android.graphics.Bitmap import app.cash.turbine.test +import coil3.ImageLoader import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Before @@ -13,7 +13,6 @@ import org.mockito.kotlin.never import org.mockito.kotlin.verify import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever -import to.bitkit.data.PubkyImageCache import to.bitkit.data.PubkyStore import to.bitkit.data.PubkyStoreData import to.bitkit.data.keychain.Keychain @@ -32,7 +31,7 @@ class PubkyRepoTest : BaseUnitTest() { private val pubkyService = mock() private val keychain = mock() - private val imageCache = mock() + private val imageLoader = mock() private val pubkyStore = mock() @Before @@ -45,7 +44,7 @@ class PubkyRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, pubkyService = pubkyService, keychain = keychain, - imageCache = imageCache, + imageLoader = imageLoader, pubkyStore = pubkyStore, ) @@ -189,7 +188,6 @@ class PubkyRepoTest : BaseUnitTest() { assertNull(sut.profile.value) assertFalse(sut.isAuthenticated.value) verifyBlocking(keychain, atLeastOnce()) { delete(Keychain.Key.PAYKIT_SESSION.name) } - verify(imageCache).clear() verifyBlocking(pubkyStore) { reset() } } @@ -205,50 +203,6 @@ class PubkyRepoTest : BaseUnitTest() { assertFalse(sut.isAuthenticated.value) } - @Test - fun `cachedImage should delegate to imageCache`() = test { - val testUri = "pubky://test_image" - val bitmap = mock() - whenever(imageCache.memoryImage(testUri)).thenReturn(bitmap) - - val result = sut.cachedImage(testUri) - - assertEquals(bitmap, result) - } - - @Test - fun `cachedImage should return null when not cached`() = test { - whenever(imageCache.memoryImage(any())).thenReturn(null) - - val result = sut.cachedImage("pubky://missing") - - assertNull(result) - } - - @Test - fun `fetchImage should return cached image from disk`() = test { - val testUri = "pubky://disk_cached" - val bitmap = mock() - whenever(imageCache.image(testUri)).thenReturn(bitmap) - - val result = sut.fetchImage(testUri) - - assertTrue(result.isSuccess) - assertEquals(bitmap, result.getOrNull()) - verify(pubkyService, never()).fetchFile(any()) - } - - @Test - fun `fetchImage should fail when fetch throws`() = test { - val testUri = "pubky://failing_image" - whenever(imageCache.image(testUri)).thenReturn(null) - whenever(pubkyService.fetchFile(testUri)).thenThrow(RuntimeException("Network error")) - - val result = sut.fetchImage(testUri) - - assertTrue(result.isFailure) - } - @Test fun `displayName should return null when no profile and no cache`() = test { sut.displayName.test(timeout = 500.milliseconds) { diff --git a/docs/pubky.md b/docs/pubky.md index 0f8c4424f..5da371b1d 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -94,35 +94,34 @@ ContactsIntroScreen → (if authenticated) ContactsScreen → ContactDetailScree ## PubkyImage Component -Composable for loading and displaying images from `pubky://` URIs, backed by `PubkyImageViewModel`. +Composable for loading and displaying images from `pubky://` URIs, backed by Coil 3. ### Architecture -- `PubkyImageViewModel` manages image state as a `Map` keyed by URI -- The composable calls `viewModel.loadImage(uri)` via `LaunchedEffect` -- Image fetching is delegated to `PubkyRepo.fetchImage()` which handles cache lookup and network fetching -- Business logic follows `UI -> ViewModel -> Repository -> RUST` flow +- `PubkyImage` is a stateless composable wrapping Coil's `SubcomposeAsyncImage` +- `PubkyImageFetcher` is a Coil `Fetcher` that handles `pubky://` URIs via `PubkyService.fetchFile()` +- `ImageModule` provides a singleton `ImageLoader` with `PubkyImageFetcher.Factory`, memory cache, and disk cache -### Caching Strategy (`PubkyImageCache`) +### Caching Strategy (Coil) -Two-tier cache: +Coil manages a two-tier cache automatically: -1. **Memory** — `ConcurrentHashMap` for instant access -2. **Disk** — files in `cacheDir/pubky-images/`, keyed by SHA-256 hash of the URI +1. **Memory** — Coil's `MemoryCache` (15% of app memory) +2. **Disk** — Coil's `DiskCache` in `cacheDir/pubky-images/` ### Loading Flow -1. Check memory cache → return if hit -2. Check disk cache → decode, populate memory, return if hit -3. Fetch via `PubkyRepo.fetchImage(uri)` which delegates to `PubkyService.fetchFile()` +1. Coil checks memory cache → return if hit +2. Coil checks disk cache → return if hit +3. `PubkyImageFetcher.fetch()` calls `PubkyService.fetchFile(uri)` 4. If response is a JSON file descriptor with a `src` field, follow the indirection and fetch the blob -5. Decode and store via `PubkyImageCache.decodeAndStore()` +5. Coil decodes and caches the result -### Display States (`PubkyImageState`) +### Display States - **Loading** — `CircularProgressIndicator` -- **Loaded** — circular-clipped `Image` -- **Failed** — fallback user icon on gray background +- **Loaded** — circular-clipped image (handled by Coil's success state) +- **Error** — fallback user icon on gray background ## Domain Model (`PubkyProfile`) @@ -144,7 +143,8 @@ Two-tier cache: |---|---| | `services/PubkyService.kt` | FFI wrapper | | `repositories/PubkyRepo.kt` | Auth state and session management | -| `data/PubkyImageCache.kt` | Two-tier image cache | +| `data/PubkyImageFetcher.kt` | Coil fetcher for pubky:// URIs | +| `di/ImageModule.kt` | Hilt module providing ImageLoader | | `data/PubkyStore.kt` | DataStore for cached profile metadata | | `models/PubkyProfile.kt` | Domain model | | `ui/components/PubkyImage.kt` | Image composable | From ae06f53b49bca394da703f75cc8b58d0aaf1a989 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 13 Mar 2026 12:02:50 +0100 Subject: [PATCH 3/6] feat: add crossfade and spring pop to pubky images Co-Authored-By: Claude Opus 4.6 --- app/build.gradle.kts | 1 + app/src/main/java/to/bitkit/di/ImageModule.kt | 2 ++ .../to/bitkit/ui/components/PubkyImage.kt | 33 +++++++++++++++++++ gradle/libs.versions.toml | 3 +- 4 files changed, 38 insertions(+), 1 deletion(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 12763aa04..c1626b903 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -268,6 +268,7 @@ dependencies { implementation(libs.haze) implementation(libs.haze.materials) // Image Loading + implementation(platform(libs.coil.bom)) implementation(libs.coil.compose) // Compose Navigation implementation(libs.navigation.compose) diff --git a/app/src/main/java/to/bitkit/di/ImageModule.kt b/app/src/main/java/to/bitkit/di/ImageModule.kt index 1d1d1d164..8ad67091e 100644 --- a/app/src/main/java/to/bitkit/di/ImageModule.kt +++ b/app/src/main/java/to/bitkit/di/ImageModule.kt @@ -5,6 +5,7 @@ import coil3.ImageLoader import coil3.disk.DiskCache import coil3.disk.directory import coil3.memory.MemoryCache +import coil3.request.crossfade import dagger.Module import dagger.Provides import dagger.hilt.InstallIn @@ -24,6 +25,7 @@ object ImageModule { @ApplicationContext context: Context, pubkyService: PubkyService, ): ImageLoader = ImageLoader.Builder(context) + .crossfade(true) .components { add(PubkyImageFetcher.Factory(pubkyService)) } .memoryCache { MemoryCache.Builder() diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt index eeac776fb..e958c32cd 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt @@ -1,5 +1,9 @@ package to.bitkit.ui.components +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.size @@ -7,9 +11,15 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.Dp @@ -37,6 +47,29 @@ fun PubkyImage( ) } }, + success = { + var loaded by remember { mutableStateOf(false) } + val scale by animateFloatAsState( + targetValue = if (loaded) 1f else 0.8f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + label = "pubky_image_scale", + ) + LaunchedEffect(Unit) { loaded = true } + Image( + painter = painter, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .matchParentSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + }, + ) + }, error = { Box( contentAlignment = Alignment.Center, diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 68c8cc9c0..9c07ee8a9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -89,7 +89,8 @@ work-runtime-ktx = { module = "androidx.work:work-runtime-ktx", version = "2.11. zxing = { module = "com.google.zxing:core", version = "3.5.4" } lottie = { module = "com.airbnb.android:lottie-compose", version = "6.7.1" } charts = { module = "io.github.ehsannarmani:compose-charts", version = "0.2.0" } -coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } +coil-bom = { module = "io.coil-kt.coil3:coil-bom", version.ref = "coil" } +coil-compose = { module = "io.coil-kt.coil3:coil-compose" } haze = { module = "dev.chrisbanes.haze:haze", version.ref = "haze" } haze-materials = { module = "dev.chrisbanes.haze:haze-materials", version.ref = "haze" } From d16bbe1ef74b7adfc3a6d3d86076ea11fb4c753e Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Fri, 13 Mar 2026 13:00:11 +0100 Subject: [PATCH 4/6] refactor: remove modifiers trailing comma --- .../main/java/to/bitkit/ui/components/PubkyImage.kt | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt index e958c32cd..7d763a168 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt @@ -43,7 +43,7 @@ fun PubkyImage( CircularProgressIndicator( strokeWidth = 2.dp, color = Colors.White32, - modifier = Modifier.size(size / 3), + modifier = Modifier.size(size / 3) ) } }, @@ -67,7 +67,7 @@ fun PubkyImage( .graphicsLayer { scaleX = scale scaleY = scale - }, + } ) }, error = { @@ -75,18 +75,18 @@ fun PubkyImage( contentAlignment = Alignment.Center, modifier = Modifier .matchParentSize() - .background(Colors.Gray5, CircleShape), + .background(Colors.Gray5, CircleShape) ) { Icon( painter = painterResource(R.drawable.ic_user_square), contentDescription = null, tint = Colors.White32, - modifier = Modifier.size(size / 2), + modifier = Modifier.size(size / 2) ) } }, modifier = modifier .size(size) - .clip(CircleShape), + .clip(CircleShape) ) } From 6ae1a821f355ddf6f6ea492f9360086bc20e8b01 Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 16 Mar 2026 15:12:44 +0100 Subject: [PATCH 5/6] refactor: use AsyncImage vs. SubcomposeAsyncImage Co-Authored-By: Claude Opus 4.6 (1M context) --- .../to/bitkit/ui/components/PubkyImage.kt | 157 +++++++++++++----- 1 file changed, 112 insertions(+), 45 deletions(-) diff --git a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt index 7d763a168..3e59c5ba9 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt @@ -3,15 +3,18 @@ package to.bitkit.ui.components import androidx.compose.animation.core.Spring import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.spring -import androidx.compose.foundation.Image +import androidx.compose.animation.core.tween import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.Icon import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -22,10 +25,12 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp -import coil3.compose.SubcomposeAsyncImage +import coil3.compose.AsyncImage import to.bitkit.R +import to.bitkit.ui.theme.AppThemeSurface import to.bitkit.ui.theme.Colors @Composable @@ -34,47 +39,75 @@ fun PubkyImage( size: Dp, modifier: Modifier = Modifier, ) { - SubcomposeAsyncImage( - model = uri, - contentDescription = null, - contentScale = ContentScale.Crop, - loading = { - Box(contentAlignment = Alignment.Center) { - CircularProgressIndicator( - strokeWidth = 2.dp, - color = Colors.White32, - modifier = Modifier.size(size / 3) - ) - } - }, - success = { - var loaded by remember { mutableStateOf(false) } - val scale by animateFloatAsState( - targetValue = if (loaded) 1f else 0.8f, - animationSpec = spring( - dampingRatio = Spring.DampingRatioMediumBouncy, - stiffness = Spring.StiffnessLow, - ), - label = "pubky_image_scale", - ) - LaunchedEffect(Unit) { loaded = true } - Image( - painter = painter, - contentDescription = null, - contentScale = ContentScale.Crop, + var imageState by remember { mutableStateOf(ImageState.Loading) } + + val scale by animateFloatAsState( + targetValue = if (imageState == ImageState.Success) 1f else 0.8f, + animationSpec = spring( + dampingRatio = Spring.DampingRatioMediumBouncy, + stiffness = Spring.StiffnessLow, + ), + label = "pubky_image_scale", + ) + + Box( + contentAlignment = Alignment.Center, + modifier = modifier + .size(size) + .clip(CircleShape) + ) { + AsyncImage( + model = uri, + contentDescription = null, + contentScale = ContentScale.Crop, + onSuccess = { imageState = ImageState.Success }, + onError = { imageState = ImageState.Error }, + modifier = Modifier + .matchParentSize() + .graphicsLayer { + scaleX = scale + scaleY = scale + } + ) + + ImageOverlay(state = imageState, size = size) + } +} + +private enum class ImageState { Loading, Success, Error } + +@Composable +private fun ImageOverlay(state: ImageState, size: Dp) { + val loadingAlpha by animateFloatAsState( + targetValue = if (state == ImageState.Loading) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "loading_alpha", + ) + val errorAlpha by animateFloatAsState( + targetValue = if (state == ImageState.Error) 1f else 0f, + animationSpec = tween(durationMillis = 300), + label = "error_alpha", + ) + + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxSize() + ) { + if (loadingAlpha > 0f) { + GradientCircularProgressIndicator( + strokeWidth = 2.dp, modifier = Modifier - .matchParentSize() - .graphicsLayer { - scaleX = scale - scaleY = scale - } + .size(size / 3) + .graphicsLayer { alpha = loadingAlpha } ) - }, - error = { + } + + if (errorAlpha > 0f) { Box( contentAlignment = Alignment.Center, modifier = Modifier - .matchParentSize() + .fillMaxSize() + .graphicsLayer { alpha = errorAlpha } .background(Colors.Gray5, CircleShape) ) { Icon( @@ -84,9 +117,43 @@ fun PubkyImage( modifier = Modifier.size(size / 2) ) } - }, - modifier = modifier - .size(size) - .clip(CircleShape) - ) + } + } +} + +@Preview +@Composable +private fun Preview() { + AppThemeSurface { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .background(Colors.Gray7) + .padding(16.dp) + ) { + ImageState.entries.forEach { state -> + Column(horizontalAlignment = Alignment.CenterHorizontally) { + BodyMSB(state.name) + VerticalSpacer(16.dp) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(64.dp) + .clip(CircleShape) + .background(Colors.Black) + ) { + if (state == ImageState.Success) { + Icon( + painter = painterResource(R.drawable.ic_user_square), + contentDescription = null, + tint = Colors.Brand, + modifier = Modifier.size(32.dp) + ) + } + ImageOverlay(state = state, size = 64.dp) + } + } + } + } + } } From 94b2b5a7942689a24320e4cb9e652ee8bdae585a Mon Sep 17 00:00:00 2001 From: Ovi Trif Date: Mon, 16 Mar 2026 16:40:20 +0100 Subject: [PATCH 6/6] fix: address PR review remarks Co-Authored-By: Claude Opus 4.6 (1M context) --- .../to/bitkit/repositories/PubkyRepoTest.kt | 25 +++++++++++++++++++ docs/pubky.md | 2 +- 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt index fbce7d730..f79e80ae6 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -2,6 +2,8 @@ package to.bitkit.repositories import app.cash.turbine.test import coil3.ImageLoader +import coil3.disk.DiskCache +import coil3.memory.MemoryCache import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.runBlocking import org.junit.Before @@ -191,6 +193,29 @@ class PubkyRepoTest : BaseUnitTest() { verifyBlocking(pubkyStore) { reset() } } + @Test + fun `signOut should evict pubky images from caches`() = test { + authenticateForTesting() + val pk = checkNotNull(sut.publicKey.value) + val ffiProfile = mock() + whenever(ffiProfile.name).thenReturn("Test") + whenever(ffiProfile.image).thenReturn("pubky://image_uri") + whenever(pubkyService.getProfile(pk)).thenReturn(ffiProfile) + sut.loadProfile() + + val memoryCache = mock() + val diskCache = mock() + val memoryCacheKey = MemoryCache.Key("pubky://image_uri") + whenever(memoryCache.keys).thenReturn(setOf(memoryCacheKey)) + whenever(imageLoader.memoryCache).thenReturn(memoryCache) + whenever(imageLoader.diskCache).thenReturn(diskCache) + + sut.signOut() + + verify(memoryCache).remove(memoryCacheKey) + verify(diskCache).remove("pubky://image_uri") + } + @Test fun `signOut should force sign out when server sign out fails`() = test { authenticateForTesting() diff --git a/docs/pubky.md b/docs/pubky.md index 5da371b1d..2fbdc0612 100644 --- a/docs/pubky.md +++ b/docs/pubky.md @@ -98,7 +98,7 @@ Composable for loading and displaying images from `pubky://` URIs, backed by Coi ### Architecture -- `PubkyImage` is a stateless composable wrapping Coil's `SubcomposeAsyncImage` +- `PubkyImage` is a stateless composable wrapping Coil's `AsyncImage` - `PubkyImageFetcher` is a Coil `Fetcher` that handles `pubky://` URIs via `PubkyService.fetchFile()` - `ImageModule` provides a singleton `ImageLoader` with `PubkyImageFetcher.Factory`, memory cache, and disk cache