diff --git a/app/build.gradle.kts b/app/build.gradle.kts index a257882a8..c1626b903 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -267,6 +267,9 @@ dependencies { implementation(libs.charts) implementation(libs.haze) implementation(libs.haze.materials) + // Image Loading + implementation(platform(libs.coil.bom)) + implementation(libs.coil.compose) // Compose Navigation implementation(libs.navigation.compose) androidTestImplementation(libs.navigation.testing) 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/PubkyImageFetcher.kt b/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt new file mode 100644 index 000000000..b3880a52f --- /dev/null +++ b/app/src/main/java/to/bitkit/data/PubkyImageFetcher.kt @@ -0,0 +1,50 @@ +package to.bitkit.data + +import coil3.ImageLoader +import coil3.Uri +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 = "PubkyImageFetcher" +private const val PUBKY_SCHEME = "pubky://" + +class PubkyImageFetcher( + 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: 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 new file mode 100644 index 000000000..8ad67091e --- /dev/null +++ b/app/src/main/java/to/bitkit/di/ImageModule.kt @@ -0,0 +1,41 @@ +package to.bitkit.di + +import android.content.Context +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 +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import to.bitkit.data.PubkyImageFetcher +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) + .crossfade(true) + .components { add(PubkyImageFetcher.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/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..3e59c5ba9 100644 --- a/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt +++ b/app/src/main/java/to/bitkit/ui/components/PubkyImage.kt @@ -1,78 +1,37 @@ package to.bitkit.ui.components -import android.graphics.Bitmap -import androidx.compose.foundation.Image +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring +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 +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.asImageBitmap +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 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.AsyncImage import to.bitkit.R -import to.bitkit.repositories.PubkyRepo +import to.bitkit.ui.theme.AppThemeSurface 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,13 +39,16 @@ fun PubkyImage( size: Dp, modifier: Modifier = Modifier, ) { - val viewModel: PubkyImageViewModel = hiltViewModel() - val images by viewModel.images.collectAsStateWithLifecycle() - val state = images[uri] + var imageState by remember { mutableStateOf(ImageState.Loading) } - LaunchedEffect(uri) { - viewModel.loadImage(uri) - } + 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, @@ -94,37 +56,104 @@ fun PubkyImage( .size(size) .clip(CircleShape) ) { - when (state) { - is PubkyImageState.Loaded -> { - Image( - bitmap = state.bitmap.asImageBitmap(), + 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 + .size(size / 3) + .graphicsLayer { alpha = loadingAlpha } + ) + } + + if (errorAlpha > 0f) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .graphicsLayer { alpha = errorAlpha } + .background(Colors.Gray5, CircleShape) + ) { + Icon( + painter = painterResource(R.drawable.ic_user_square), contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier.matchParentSize() + tint = Colors.White32, + modifier = Modifier.size(size / 2) ) } - 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) - ) + } + } +} + +@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) + } } } - else -> { - CircularProgressIndicator( - strokeWidth = 2.dp, - color = Colors.White32, - modifier = Modifier.size(size / 3) - ) - } } } } 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..f79e80ae6 100644 --- a/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt +++ b/app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt @@ -1,7 +1,9 @@ package to.bitkit.repositories -import android.graphics.Bitmap 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 @@ -13,7 +15,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 +33,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 +46,7 @@ class PubkyRepoTest : BaseUnitTest() { ioDispatcher = testDispatcher, pubkyService = pubkyService, keychain = keychain, - imageCache = imageCache, + imageLoader = imageLoader, pubkyStore = pubkyStore, ) @@ -189,64 +190,42 @@ 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() } } @Test - fun `signOut should force sign out when server sign out fails`() = test { + fun `signOut should evict pubky images from caches`() = test { authenticateForTesting() - whenever(pubkyService.signOut()).thenThrow(RuntimeException("Server error")) - - val result = sut.signOut() - - assertTrue(result.isSuccess) - verifyBlocking(pubkyService) { forceSignOut() } - 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) - } + 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() - @Test - fun `cachedImage should return null when not cached`() = test { - whenever(imageCache.memoryImage(any())).thenReturn(null) + 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) - val result = sut.cachedImage("pubky://missing") + sut.signOut() - assertNull(result) + verify(memoryCache).remove(memoryCacheKey) + verify(diskCache).remove("pubky://image_uri") } @Test - fun `fetchImage should return cached image from disk`() = test { - val testUri = "pubky://disk_cached" - val bitmap = mock() - whenever(imageCache.image(testUri)).thenReturn(bitmap) + fun `signOut should force sign out when server sign out fails`() = test { + authenticateForTesting() + whenever(pubkyService.signOut()).thenThrow(RuntimeException("Server error")) - val result = sut.fetchImage(testUri) + val result = sut.signOut() 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) + verifyBlocking(pubkyService) { forceSignOut() } + assertFalse(sut.isAuthenticated.value) } @Test diff --git a/docs/pubky.md b/docs/pubky.md index 0f8c4424f..2fbdc0612 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 `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 -### 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 | diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 62858901e..9c07ee8a9 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,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-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" }