fix: speed up pubky profile loading - #1399
Conversation
|
Regtest APKDownload bitkit-dev-debug universal APK (expires in 30 days). |
A Ring row now shows a spinner in its avatar slot until its profile lookup finishes, whatever the outcome, so a row still looking up no longer looks like one whose identity has no profile. The adoption spinner moves into the row's key icon circle, matching iOS.
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Scope: Follow-up of the changes since b001a3c, the head of the previous full review, at 97bde5d. The comparison base and merge base are still 6ba44a4079c5ee02625eac59a56d6a55a6297431. This pass covered the contact-import leave, failure, and identity-change delta. Inherited baseline: the full pull request diff reviewed at b001a3c, including public profile reads, stale profile writes, and the import owner check under the Paykit lock.
No new actionable code findings.
Leaving the import overview no longer drops a failed background save. Back and the drawer call discardPendingImport(), which keeps the pending follows while a save is still running. Pay Contacts opens only after a successful import, and Back on the overview does not open it. A failed import sets contactImportFailure, and AppViewModel shows that error after the import screens are gone. An import stopped because the identity changed does not raise that toast.
Validation: build, lint, and detekt succeeded on this revision. The new unit tests in PubkyRepoTest.kt, ContactImportOverviewViewModelTest.kt, and AppViewModelSendFlowTest.kt were inspected and not executed here. Device testing was not performed. bitkit-ios#854 was not reviewed.
Device testing: not performed in this review.
Ready for device testing.
|
Testing Bitkit <> Ring profile import. Staging. Screen.Recording.2026-10-02.at.11.08.41.movCan be compared with logs from #1406 (comment)... the same profile was imported. improvement is mainly on step 2. |
|
One nit that could be addressed here. After tap on "Delete profile" currently there's no spinner screen. It is present on iOS. |
refreshKnownSavedContactEndpoints iterated the live saved contact key set across suspending receiver lookups. A contact saved while a lookup was suspended mutated the set and the resumed loop threw ConcurrentModificationException.
|
Thanks. Timings from your logs, compared with the #1401 run on the same 61-follow profile:
The logs don't show when Import was tapped, so the middle row is an upper bound. Even so, the import itself took at most 11 s on this build, which matches the gain you saw in step 2. Preview. This took longer on this run, because of 3 follows whose profile lookup took about 5 minutes to fail with Continue. This is still the receiver-path walk from #1406, which #1401 covers. At the end of this log it is still retrying the same 7 follows. One more thing in the log. At 09:13:48, Next, I'm reproducing your run with about 60 follows to look at the preview time and any other lag. |
No need to address this here. #1394 adds deletion progress using a spinner on the Delete Profile button. I’ll raise the full-screen presentation difference there. |
|
Thanks, I'll leave the delete spinner to #1394 and won't change it here. |
|
Correction to my earlier reply: the 7 failing follows are not behind an unreachable homeserver. Six of them have no pkarr record on the relays, and the seventh ( In our 61-follow runs on an emulator, using the same follow list, they fail in 5–9 s each, as on iOS. The 5-minute failures did not reproduce. The SDK limits each HTTP request to 30 s, but the pkarr and DHT lookups have no overall deadline. A slow DHT on your network is the most likely cause, but that is unconfirmed. |
A follow whose profile read is still running 10 seconds after it got a read slot is cancelled, which frees the slot, and shows in the preview as a placeholder. Waiting for a slot does not count, and the timeout surfaces as PaykitReadTimeoutError, an ordinary failure.
A Contacts load no longer looks up again every contact whose profile this session already resolved, so returning from a contact's screen does not re-resolve the whole list. Contacts without a resolved profile are still looked up on every load, and sign-out or an identity change clears the state.
A background refresh now updates the contact list at most once every 300 ms, sorting it once per batch, and applies the last batch as soon as its lookups finish. A batch overtaken by a sign-out or an identity change is dropped, and a contact screen applies a profile still waiting for its batch at once.
A missing, oversize or invalid avatar is not fetched again until the image cache is cleared, and an avatar whose fetch failed for another reason is retried after 60 seconds. Sign-out and an identity change forget every failure.
|
Repeated the test on Samsung s22 device. The results are similar as on emulator. Actually step 2 here was also slow. Step 3 the slowest. I'm ~15 min in and still in progress. Will post logs once it finishes. |
|
Device retest of the 61-follow Ring import on a Samsung, build
The seven unpublished follows failed in about 5–15 s. The recording is trimmed to the import list and arrival on Pay Contacts. bk1399-import-samsung.mp4 |
…bky-profile-load-lag
|
Thanks for the device run. I've pushed b46f007, which merges
Janky frames on Contacts dropped from 44–55% to 13–28% (16–26% on Not changed here: "Let your contacts pay you" (about 2 min 15 s on the emulator) and Delete Profile with many contacts. Both wait on the private payment link setup, which walks every contact one at a time. #1401 rewrites that code. |
piotr-iohk
left a comment
There was a problem hiding this comment.
QA review
Re-reviewed the full PR at b46f007c for an independent second pass, including affected callers, SDK contracts and tests.
No additional actionable code findings from the full reassessment.
The earlier background import failure and stale metadata concern are fixed in the current source. The remaining private-payment setup latency described in the latest performance update still needs runtime measurement; its serialized path predates this PR. This review does not establish that it is resolved.
Validation: 285 Android unit tests passed across nine relevant classes at this head, covering profile/import state, contact editing, image caching, SDK read admission and Ring choice state. Broader test coverage was inspected. Native throughput and cancellation latency were not profiled.
The Android/iOS shared behavior and all four profile journeys were independently reassessed. One additional queued-tag lifecycle finding belongs to iOS #854; it does not affect this Android implementation.
Suggested additional test cases
- Android: with 60+ contacts and delayed profile responses, open a rich contact, add a tag and save. Edit its name while other profiles finish. Expect bio, avatar and links preserved, and unsaved fields kept intact.
- Android: start a large import, leave the screen, then sign out or wipe and activate another identity before it finishes. Expect no old contacts, completion navigation or old-identity failure toast on the new identity.
- Android: while contact refresh and private endpoint discovery run, open an auth link and a contact payment. Record elapsed waits separately for public reads and serialized private work; verify cancellation/recovery and absence of duplicate operations.
- Android, with PIN enabled: let cold auth restoration time out, then scan an ordinary invoice. Expect the normal invoice confirmation to remain reachable after unlock.
Device testing: not performed in this review. Earlier device evidence remains attributed to its original builds.
There was a problem hiding this comment.
Verdict: ✅ Approve
Review: diff 52 files.
QA:
Tested on Android 16 emulator (Pixel 10 Pro), staging.
🟢 Tests 1, 2, 3, 4, 5, J1, J2, J3, J4
Test 1
With Pubky Ring holding a published pubky and a never-published one, open the profile choice screen → both rows show at once with spinners, and the published name appears without….
1.mp4 |
![]() | ![]() |
Test 2
Tap the published Ring row → only that row spins, the other row is disabled, and Pay Contacts opens — Pubky Ring not in Capabilities.
2.mp4 |
![]() | ![]() |
Test 3
Force an adopt failure (airplane mode right after the tap) → the rows stay visible with their names and can be tapped again — Pubky Ring not in Capabilities.
3.mp4 |
![]() | ![]() |
Test 4
Adopt a Ring identity that follows several pubkys, some never published, then Import All → every follow is saved (unpublished ones under their key), Contacts lists them at once….
4.mp4 |
![]() | ![]() | ![]() | ![]() |
Test 5
With a saved Pubky identity: turn on airplane mode, force-stop Bitkit, open a pubkyauth:// link (Enable Payments in pubky.app), and turn airplane mode off within a few seconds →….
5.mp4 |
![]() | ![]() |
Test J1
Verifies that opening Profile straight after a relaunch, while the signed-in profile is still loading, shows the cached name read-only.
J1.mp4 |
![]() |
Test J2
Verifies that the Pubky Ring choice screen lists every Ring identity straight away under its truncated key and fills each name in as its profile lookup….
J2.mp4 |
![]() | ![]() | ![]() | ![]() |
Test J3
A contact import keeps saving after you leave the import overview, and when it finishes it leaves you where you went instead of opening Pay Contacts.
J3.mp4 |
![]() | ![]() | ![]() |
Test J4
Contacts shows the saved contacts as soon as it opens after a relaunch, under the names they were saved with, and fills in each contact's profile name and….
J4.mp4 |
![]() | ![]() | ![]() | ![]() |
Tip
Tests 1, 2, 3, 4, 5 worth a journey
Test 1
- Open Profile from Home and tap Continue
- See both Pubky Ring rows at once
- See AdLovelace while the other row still shows Loading your profile
- See the other row settle on its truncated key
Test 2
- Tap the AdLovelace Ring row
- See a spinner only on that row, in place of its key icon
- See Pay Contacts open
Test 3
- Open the Ring choice screen until both names are visible
- Tap AdLovelace and turn airplane mode on
- See both rows stay, with AdLovelace and the truncated key
- Tap AdLovelace again and see only that row spin, then the names remain
Test 4
- Adopt the published Ring identity
- See the import screen count 16 friends
- Import all of them
- Open Contacts and see the published names and the unpublished keys
- Save a photo and see it as the profile avatar
Test 5
- Turn on airplane mode and force-stop Bitkit
- Open a pubky auth link
- Turn airplane mode off within a few seconds
- See the approval sheet
- Cold-start the same link and stay offline
- See Couldn't Load Your Pubky Profile
Reviewed by grok-4.7-xhigh via gh-pr-review-loop skill
Commands: @ovi-reviewer review · test · retest (author)
piotr-iohk
left a comment
There was a problem hiding this comment.
Retested the current build on a Samsung with the same 61-follow Ring profile. Same result as the earlier device run.
The import list was ready in about 17 seconds. Continue still takes a few minutes on the seven unpublished follows, then stops. That wait is #1406 and belongs with #1401. Approving this for the profile-loading change.
























Twin: synonymdev/bitkit-ios#854
Fixes the lag when loading pubky profiles and importing contacts, which got much worse with the shared Pubky Ring identities from #1329.
Where the lag came from. All Paykit calls go through one lock, and each call kept it for its whole network round trip. That included simple public reads, like fetching a profile name or avatar, or checking how a contact can be paid. So lookups queued behind each other, behind sign-in, and behind background Paykit work. A pubky that was never published takes 2–7 s to fail, and everything else waited for it. On top of that:
How this fixes it. Public reads skip the lock and run side by side, up to six at once. Background reads (the contact list, private payment setup) may use at most four of those six, so what you are looking at (your avatar, your profile, the Ring rows) never waits behind them. Anything that touches your session, keys or saved Paykit data still goes through the lock. The choice screen shows its rows straight away with a spinner on each. The import keeps running if you leave the screen. Saving the imported contacts without new lookups came from #1395, which this branch now includes from
master. Contacts shows your saved contacts at once and fills in names and avatars as they load.The Import All and saved-follows rows were measured against
masterbefore #1395, which now givesmasterthe same import save.With QA's 61-follow Pubky Ring profile (emulator, staging network; 54 follows have profiles and 7 were never published):
master"Let your contacts pay you" still takes about 2 minutes 15 seconds (5 minutes 39 seconds on
master), and Delete Profile with 61 contacts can take minutes. See Out of Scope.Description
master#1395. #1395 merged, and this branch now includes it from
master; its import save replaced this branch's own. What this PR adds to the import: it keeps running after you leave, checks the identity under the Paykit lock before each save, and fills the session profile cache so Contacts shows the new contacts at once.Out of Scope
app/src/main/java/to/bitkit/repositories/PubkyRepo.kt: app launch still builds the Paykit SDK and restores the session twice; removing that needs Paykit inputmaster; saving only the changed fields needs a new override formatrustls_platform_verifier"BKS KeyStore not available" TLS errors on the emulator image; they were there before this PR and can make a lookup failHow to review
Production code is about a quarter of this PR; most of the rest is tests (about 2,590 lines, with near-duplicate cases merged into case tables).
docs/pubky.mdis the shortest summary of the new rules. Suggested order, one area at a time:PaykitSdkService.kt(publicRead,PaykitReadLane, the background identity refresh),PaykitSdkOperationLock.kt(the wipe check without the lock) andPubkyService.kt(cancellable reads). Tests:PaykitSdkServiceTest,PaykitSdkOperationLockTest,PaykitSdkServiceWipeTest,PubkyServiceTest,PubkyIdentityRepublishTestPubkyRepo.kt,loadProfile,fetchDisplayProfile,adoptRingIdentityand the profile writes; thenPubkyChoiceViewModel/Screen,ProfileViewModel/Screen,PubkyImageFetcher.kt,PubkyImageCacheEpoch.ktandPubkyStore.kt. Tests:PubkyRepoTest,PubkyChoiceViewModelTest,ProfileViewModelTest,PubkyImageFetcherTest,PubkyStoreTestPubkyRepo.kt,loadContacts,ContactProfileRefresh(with its batches and freshness window),resolvePendingContactProfile,importContactsandprepareImport(the per-follow deadline); then the contacts view models and screens. Tests:PubkyRepoTest,ContactsViewModelTest,ContactDetailViewModelTest,EditContactViewModelTest,ContactImportOverviewViewModelTest,ContactImportSelectViewModelTestPrivatePaykitRepo.ktandPaykitPaymentRequestRepo.kt, which only choose a read laneAppViewModel.kt(awaitPubkyDeeplinkInitialization),PubkyRepo.awaitIdentityReadyandstrings.xml. Test:AppViewModelSendFlowTestjourneys/pubky-profile/What to look for: reads no longer queue behind the Paykit lock, so a result can now arrive late, after a sign-out, an identity switch, a newer save or a wallet wipe. Most of the new code drops those late results (owner checks, generation counters, the wipe check). Each of those guards has a test that holds a fake Paykit call halfway, runs the competing action, then releases the call.
Design
N/A — no design available. The spinners reuse existing components.
Preview
Choosing a Pubky Ring identity
android-before-after.mp4
Left:
master(dbbf90f). Right: this PR (1f4364f; later commits do not change this flow). Same emulator, same taps, fresh app data, staging network, one take each. After the profile intro,mastershows only "Loading your profile…" for about 11 s. With this PR the rows show at once, each row's spinner turns into its name or avatar, and adopting spins only the tapped row.Setting up a profile and importing contacts
android-import-before-after.mp4
Left:
master(5e990b1). Right: this PR (494db5f, recorded before #1395 merged; the import save now comes from #1395). Same emulator, same taps, staging network, one take each, aligned on the Ring row tap. Each take chooses the Pubky Ring identity, taps Import All, then opens Contacts and Profile. The test identity has no real pubky.app follows, so both builds use the same debug-only hook that returns 8 follows: 2 published and 6 never published, all created for this test. Onmasterthe import screen takes about 64 s, Import All about 44 s, only the 2 published follows are saved, and the Profile avatar spins for about 7 s. With this PR the import screen takes about 11 s, Import All about 1 s, all 8 follows are saved, and the avatar shows at once.QA Notes
Journeys
cached-profile-header.xml— Profile opened while loading shows the cached name and avatar without edit actions, then the full profilering-choice-rows.xml— Pubky Ring rows show at once with a spinner each until their profile loads; adopting spins only the tapped row and disables the restcontact-import-after-leaving.xml— leaving the import screen right after Import All stays on Home, never opens Pay Contacts, and Contacts later lists every followcontacts-list-loading.xml— saved contacts show at once with their stored names, profiles fill in, and the full-screen spinner shows only until the first loadManual Tests
pubkyauth://link (Enable Payments in pubky.app), and turn airplane mode off within a few seconds → the approval sheet appears, not "Pubky Identity Required"; if you stay offline → "Couldn't Load Your Pubky Profile" — airplane mode is outside journey capabilitiesAutomated Checks
PubkyStoreTest.kt— the cached profile ownerContactsViewModelTest.kt— the full-screen spinner shows only until the saved contacts first loadPaykitSdkServiceTest.kt— a read deadline counts only the time a read holds its slot, a contact save queued behind an identity change is not saved to the new identity, public reads and locked work do not wait for each other, reads are capped at six with two kept for what you are looking at, a freed read slot goes to an interactive read before queued bulk reads, a cancelled slot waiter takes no slot and passes on one it was handed, receiver reads run outside the lock, a read with no SDK builds it under the lock, activation does not wait for the refresh, and an approval waits for a refresh activation startedPaykitSdkOperationLockTest.ktandPaykitSdkServiceWipeTest.kt— public reads are refused during a wallet wipe and dropped when a wipe overtakes themPubkyServiceTest.kt— cancelling a public read cancels the underlying callPubkyIdentityRepublishTest.kt— approvals still wait for the identity refreshPubkyRepoTest.kt— stale profile loads are dropped after a write, the retry policy per caller, the adopt handoff, the import outliving its screen, stopping on an identity change and clearing the pending import only on success, the Contacts list publishing before lookups finish, skipping lookups for profiles found in the last 10 minutes, applying refresh results in batches and dropping a stale batch, the per-follow deadline on the import screen, and the auth link waiting for a session restorePrivatePaykitRepoTest.kt,PaykitPaymentRequestRepoTest.ktandPaykitPaymentRequestRepoSubscriptionTest.kt— background and interactive read slots for private sync and payment checks, and a private link refresh that survives a contact being saved mid-refreshContactImportOverviewViewModelTest.kt,ContactImportSelectViewModelTest.kt,ContactDetailViewModelTest.ktandEditContactViewModelTest.kt— repeat taps ignored while an import runs, the overview leaving once its import finishes elsewhere, and loading a contact's profile before editing itAppViewModelSendFlowTest.kt— a contact import failure shows an error after the import screens are gone, the auth link waits for a session retry, shows the retryable error for a saved identity it can't restore, and private sync ignores contact row updatesPubkyChoiceViewModelTest.kt— rows show before lookups finish, each row's spinner clears whether its lookup finds a profile, finds nothing or fails, and lookups continue after a failed adoptionProfileViewModelTest.kt— cached header only while loading and only for the current key, refresh on open, and one more load when a running load failsPubkyImageFetcherTest.kt— disk cache hits, no writes after a clear or for a failed download, disk errors falling back to the network, cancellation passing through the disk cache, and failed fetches remembered until a cache clear (missing or too large) or for 60 s (other errors)