feat: add profile and contacts fetching from pubky#824
feat: add profile and contacts fetching from pubky#824ben-kaufman wants to merge 21 commits intomasterfrom
Conversation
app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
Show resolved
Hide resolved
This comment has been minimized.
This comment has been minimized.
app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthViewModel.kt
Show resolved
Hide resolved
|
Starting review |
jvsena42
left a comment
There was a problem hiding this comment.
Could add some unit tests at Repo level too
app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
Outdated
Show resolved
Hide resolved
There was a problem hiding this comment.
Reviewed up to and including PubkyImage.kt, will resume later.
Test Recording:
https://github.com/user-attachments/assets/fc3f6997-981e-422f-92e4-60769232bd3d
Manually tested and found 2 critical, pls see video above:
- Crash when trying to go to Profile via Drawer.
- Dialog should use
AppAlertDialogwhich comes with theming for our design lang.
Plus a couple nits, strongest views on:
- using viewModel and no business logic in UI layer
- using DataStore instead of SharedPreferences.
Aside from these small things, it works great 🎉
| _authState.update { PubkyAuthState.Authenticated } | ||
| Logger.info("Pubky auth completed for $pk", context = TAG) | ||
| loadProfile() | ||
| }.map {} |
There was a problem hiding this comment.
nit: could return Result<Boolean> to signal if successful
Edit: fixed to show what I meant using the inline code block.
There was a problem hiding this comment.
The caller only uses .onSuccess/.onFailure — it never reads the Boolean value. Result is sufficient.
| suspend fun initialize() = | ||
| ServiceQueue.CORE.background { paykitInitialize() } |
There was a problem hiding this comment.
a bit 'dumb' but why not 🤷🏻 .
I mean if some code calls one of the methods in here before calling initialize I suspect there won't be a descriptive error message to help with debugging.
Basically the reason we did that isSetup in VssBackupClient ;)
although, this is not sdk code so it might've been not needed to implement those guards all along.
There was a problem hiding this comment.
Reconsidered this and I recommend to borrow the same isSetup "template" used by the VssBackupClient to enforce any method that needs initialize to wait for its result before running its own logic.
could introduce a global util in to.bitkit.async.Utils.kt like:
suspend inline fun <T> withAwait(
deferrable: Deferred<Unit>,
block: suspend () -> T
): T {
deferrable.await()
return block()
}then use it like:
suspend fun importSession(secret: String): String = ServiceQueue.CORE.background {
withAwait(isSetup) {
paykitImportSession(secret)
}
}
app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/ProfileScreen.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/ProfileViewModel.kt
Outdated
Show resolved
Hide resolved
app/src/main/java/to/bitkit/ui/screens/profile/PubkyRingAuthScreen.kt
Outdated
Show resolved
Hide resolved
This comment has been minimized.
This comment has been minimized.
ovitrif
left a comment
There was a problem hiding this comment.
Submitted couple more pending comments and resolved the ones where code changes applied. The goal is to reach a state where agent can be asked to scan through non-resolved review comments and address those in a new pass.
This comment has been minimized.
This comment has been minimized.
|
resuming review... |
ovitrif
left a comment
There was a problem hiding this comment.
Overall great progress, this is getting closer to being ready.
For some reason my review comments of yesterday night didn't land to your side, likely it was a PEBKAC 🤦🏻 and I forgot to publish review.
There were more nits, but it was lacking the review of contacts review and retest of the new features stack. So this is more comprehensive anyways.
The biggest 2 issues are shown in this video:
https://github.com/user-attachments/assets/b582a575-39fe-4ddf-9e99-868de5a099f2
1. Crash when sharing pk from profile or contact details
1️⃣ 🔴 Basically 2 crash bugs, because shareText is used in viewModels instead of in view layer, the fix is described in comments on the lines which cause the crash. Should be fixed in profile details and contacts details.
2. Inconsistency and missing details on self (my) contact details
2️⃣ 🟠 The other bug (supposedly) is the contact details page for self, seems to be missing the right profile picture. Interesting that in iOS we show the profile page when tapping on the contact list item for self.
3. Inconsistency in sorting irregular profile names
3️⃣ 🔴 Then I noticed a difference between iOS and Android in sorting. iOS seems to do this better: Profiles with non-alphabet starting character are grouped at the top under section header #.
Android shows those at the end of the list, in a separate section for each glyph first char.
contacts.mp4
| override fun onCleared() { | ||
| super.onCleared() | ||
| if (_uiState.value.isWaitingForRing || _uiState.value.isAuthenticating) { | ||
| approvalJob?.cancel() |
There was a problem hiding this comment.
The approvalJob?.cancel() line can be removed.
info: approvalJob is launched in viewModelScope, which gets cancelled automatically when the ViewModel is cleared (right after onCleared() returns).
| private val transferRepo: TransferRepo, | ||
| private val migrationService: MigrationService, | ||
| private val coreService: CoreService, | ||
| @Suppress("UnusedPrivateProperty") private val pubkyRepo: PubkyRepo, |
There was a problem hiding this comment.
q: You're keeping this here so that PubkyRepo init spawns in parallel, together with the AppViewModel init?!
| whenever(ffiProfile.bio).thenReturn(null) | ||
| whenever(ffiProfile.image).thenReturn(null) | ||
| whenever(ffiProfile.links).thenReturn(null) | ||
| whenever(ffiProfile.status).thenReturn(null) |
There was a problem hiding this comment.
nit: mockito defaults unstubbed methods/props to null. only need to be explicit when we rely on some specific null case.
can cleanup all of such repetitions in the tests of this class.
| suspend fun initialize() = | ||
| ServiceQueue.CORE.background { paykitInitialize() } |
There was a problem hiding this comment.
Reconsidered this and I recommend to borrow the same isSetup "template" used by the VssBackupClient to enforce any method that needs initialize to wait for its result before running its own logic.
could introduce a global util in to.bitkit.async.Utils.kt like:
suspend inline fun <T> withAwait(
deferrable: Deferred<Unit>,
block: suspend () -> T
): T {
deferrable.await()
return block()
}then use it like:
suspend fun importSession(secret: String): String = ServiceQueue.CORE.background {
withAwait(isSetup) {
paykitImportSession(secret)
}
}|
|
||
| fun sharePublicKey() { | ||
| val pk = pubkyRepo.publicKey.value ?: return | ||
| shareText(context, pk) |
There was a problem hiding this comment.
This is causing the app to crash when the button is tapped.
Please use the share* methods directly in UI layer, ideally you don't even hoist callbacks up to the screen composable; can be done inside inner component directly.
example:
Presumably, I should've enforced this contract by accepting Activity instead of Context.
| } | ||
| } | ||
|
|
||
| fun sharePublicKey() = shareText(context, publicKey) |
There was a problem hiding this comment.
This will cause a crash when the calling button is tapped.
Apply same heuristics as described in the review comment in ProfileViewModel on a similar code.
| } | ||
| } | ||
|
|
||
| @Preview(showBackground = true) |
There was a problem hiding this comment.
nit: ALWAYS use showSystemUi = true instead of showBackground = true for screen previews.
| } | ||
| } | ||
|
|
||
| @Preview(showBackground = true) |
There was a problem hiding this comment.
nit: ALWATS use showSystemUi = true instead of showBackground = true for screen previews.
| } | ||
| } | ||
|
|
||
| @Preview(showBackground = true) |
There was a problem hiding this comment.
nit: ALWAYS use showSystemUi = true instead of showBackground = true for screen previews.
| } | ||
| } | ||
|
|
||
| @Preview(showBackground = true) |
There was a problem hiding this comment.
nit: ALWAYS use showSystemUi = true instead of showBackground = true for screen previews.
ovitrif
left a comment
There was a problem hiding this comment.
Added info to implement the "image loaded from remote" use-case, which needs to be setup in a correct way since we'll probably employ it more often in future implementations.
| fun PubkyImage( | ||
| uri: String, | ||
| size: Dp, | ||
| modifier: Modifier = Modifier, | ||
| ) { | ||
| val viewModel: PubkyImageViewModel = hiltViewModel() | ||
| val images by viewModel.images.collectAsStateWithLifecycle() | ||
| val state = images[uri] | ||
|
|
||
| LaunchedEffect(uri) { |
There was a problem hiding this comment.
Didn't foresee this to be used in list items, but now that it is, we need to add proper implementation.
Here's a 2-way plan to implement this properly, since "images from remote" is likely going to be needed more and more in the future:
➕ Refactor PubkyImage: Drop the Map + Add Coil
Refactor PubkyImage: Drop the Map + Add Coil
Context
PubkyImage.kt uses a @HiltViewModel with a StateFlow<Map<String, PubkyImageState>> shared across all instances on a screen. This causes unnecessary recomposition storms (every image load triggers recomposition of ALL PubkyImage instances) and duplicates the caching already handled by PubkyImageCache (memory ConcurrentHashMap + disk). The map was likely intended for request deduplication, but the cache layer already handles that.
Two-phase refactor: first simplify the architecture, then replace with Coil.
Phase 1: Drop the Map (Easy Way)
Goal: Eliminate the shared map and per-composable recomposition cascade. Each PubkyImage manages only its own state via remember.
Files to Modify
-
app/src/main/java/to/bitkit/ui/components/PubkyImage.kt- Remove the
_imagesmap StateFlow fromPubkyImageViewModel - Simplify VM to two passthrough methods:
@HiltViewModel class PubkyImageViewModel @Inject constructor( private val pubkyRepo: PubkyRepo, ) : ViewModel() { fun cachedImage(uri: String): Bitmap? = pubkyRepo.cachedImage(uri) suspend fun fetchImage(uri: String): Result<Bitmap> = pubkyRepo.fetchImage(uri) }
- Update
PubkyImagecomposable to use per-instancerememberstate:@Composable fun PubkyImage(uri: String, size: Dp, modifier: Modifier = Modifier) { val viewModel: PubkyImageViewModel = hiltViewModel() var state by remember(uri) { mutableStateOf<PubkyImageState>(PubkyImageState.Loading) } LaunchedEffect(uri) { val cached = viewModel.cachedImage(uri) if (cached != null) { state = PubkyImageState.Loaded(cached) return@LaunchedEffect } viewModel.fetchImage(uri) .onSuccess { state = PubkyImageState.Loaded(it) } .onFailure { state = PubkyImageState.Failed } } // Box with when(state) rendering stays the same }
- Keep
PubkyImageStatesealed interface as-is - Remove
import kotlinx.coroutines.flow.*,import androidx.lifecycle.viewModelScope,import kotlinx.coroutines.launch,import androidx.lifecycle.compose.collectAsStateWithLifecycle(no longer needed) - Add
import androidx.compose.runtime.setValue,import androidx.compose.runtime.mutableStateOf
- Remove the
-
No other files change - all 5 call sites use
PubkyImage(uri, size, modifier)signature unchanged
What This Fixes
- Each composable only recomposes when its own image loads
PubkyImageCache(singleton) still handles deduplication: memory check -> disk check -> network- No map, no shared mutable flow, simpler mental model
- VM becomes a thin DI bridge (necessary because composables can't inject repos directly)
Phase 2: Add Coil (Best Way)
Goal: Replace the hand-rolled image loading with Coil 3, which provides memory/disk caching, request coalescing, bitmap pooling, image downsampling, and lifecycle awareness out of the box.
Files to Modify
-
gradle/libs.versions.toml- Add Coil dependency[versions] coil = "3.2.0" [libraries] coil-compose = { module = "io.coil-kt.coil3:coil-compose", version.ref = "coil" } coil-network-ktor = { module = "io.coil-kt.coil3:coil-network-ktor3", version.ref = "coil" }
-
app/build.gradle.kts- Add dependency (after Compose section ~line 268)// Image loading implementation(libs.coil.compose) -
app/src/main/java/to/bitkit/di/- New Hilt moduleImageModule.kt- Provide singleton
ImageLoaderwith the customPubkyFetcher - Configure Coil's disk cache to use
pubky-imagesdir (matching existing cache dir for migration)
@Module @InstallIn(SingletonComponent::class) object ImageModule { @Provides @Singleton fun provideImageLoader( @ApplicationContext context: Context, fetcherFactory: PubkyFetcher.Factory, ): ImageLoader = ImageLoader.Builder(context) .components { add(fetcherFactory) } .build() }
- Provide singleton
-
app/src/main/java/to/bitkit/data/PubkyFetcher.kt- New file- Implement Coil's
Fetcherinterface for pubky:// URIs - Reuse
PubkyRepo.fetchImage()logic (resolve file descriptors, fetch blobs) - Actually, better to call
PubkyService+resolveImageDatadirectly to avoid circular dependency - Move
resolveImageDatafromPubkyRepoto a shared utility or into the fetcher
@Singleton class PubkyFetcher( private val pubkyService: PubkyService, private val uri: String, ) : Fetcher { override suspend fun fetch(): FetchResult { val data = pubkyService.fetchFile(uri) val blobData = resolveImageData(data, pubkyService) return SourceFetchResult( source = ImageSource(Buffer().apply { write(blobData) }, FileSystem.SYSTEM), mimeType = "image/*", dataSource = DataSource.NETWORK, ) } @Singleton class Factory @Inject constructor( private val pubkyService: PubkyService, ) : Fetcher.Factory<Uri> { override fun create(data: Uri, options: Options, imageLoader: ImageLoader): Fetcher? { if (data.scheme != "pubky") return null return PubkyFetcher(pubkyService, data.toString()) } } }
- Implement Coil's
-
app/src/main/java/to/bitkit/ui/components/PubkyImage.kt- Replace with Coil'sAsyncImage- Remove
PubkyImageViewModelentirely - Remove
PubkyImageStatesealed interface - Simplify to:
@Composable fun PubkyImage(uri: String, size: Dp, modifier: Modifier = Modifier) { val imageLoader = LocalContext.current.imageLoader // or inject via LocalImageLoader SubcomposeAsyncImage( model = uri.toUri(), contentDescription = null, imageLoader = imageLoader, contentScale = ContentScale.Crop, loading = { CircularProgressIndicator( strokeWidth = 2.dp, color = Colors.White32, 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) ) }
- Remove
-
app/src/main/java/to/bitkit/repositories/PubkyRepo.kt- Remove
cachedImage()method (line 262) - Remove
fetchImage()method (lines 264-272) - Remove
resolveImageData()method (lines 274-285) - moved toPubkyFetcher - Remove
imageCacheconstructor parameter and its import - Keep
imageCache.clear()call insignOut()but replace with Coil cache clearing
- Remove
-
app/src/main/java/to/bitkit/data/PubkyImageCache.kt- Delete- Coil manages its own memory + disk cache
- Cache clearing on sign-out handled via
ImageLoader.memoryCache?.clear()+ImageLoader.diskCache?.clear()
-
app/src/test/java/to/bitkit/repositories/PubkyRepoTest.kt- Remove
imageCachemock and any test cases forcachedImage/fetchImage
- Remove
-
docs/pubky.md- Update caching strategy section (~lines 106-119)- Replace
PubkyImageCachereferences with Coil
- Replace
What This Fixes (beyond Phase 1)
- Automatic bitmap pooling and memory management
- Image downsampling to display size (32dp/48dp/64dp instead of full resolution)
- Request coalescing (identical URIs in-flight are merged)
- Lifecycle-aware loading (cancels when composable leaves composition)
- Industry-standard, well-tested image pipeline
- ~70 fewer lines of custom code to maintain
Verification
After Phase 1:
./gradlew compileDevDebugKotlin- compiles./gradlew testDevDebugUnitTest- tests pass./gradlew detekt- no new lint issues- Manual: open Contacts screen with multiple contacts having pubky images, verify they load correctly and independently
After Phase 2:
- Same compile/test/lint checks
- Manual: verify images still load on all 4 screens (Contacts, ContactDetail, Profile, Home)
- Manual: verify sign-out clears image cache
- Manual: verify loading/error states render correctly
Commit Messages
After Phase 1:
refactor: simplify pubky image loading(95%)refactor: drop shared map from pubky image vm(85%)fix: remove recomposition storm in pubky images(80%)
After Phase 2:
refactor: migrate pubky images to coil(90%)feat: add coil for pubky image loading(85%)refactor: replace custom image cache with coil(80%)
Actually I will proceed to turn phase 2 of the plan into a stacked PR on top of this 🙋🏻♂️
There was a problem hiding this comment.
Added PR:
Still need to fix some issues for pubky profiles images to work, I will request review once ready.
Integrates Pubky decentralized identity into Bitkit, adding profile authentication, a contacts screen.
What's included
Pubky profile
pubkyauth://) with relay-based session exchange, session persistence in Keychain, and automatic session restoration on launchPubkyService— service layer wrappingpaykit(session management) andbitkit-core(auth relay, file fetching, profile/contacts)PubkyRepo— manages auth state, session lifecycle, profile, and contacts data with all I/O offloaded to background threadsPubkyImagecomponent for loadingpubky://URIs with two-tier (memory + disk) cachingPubkyStoreDataStore caches profile name and image URI for instant display before full profile loadsPubky contacts