feat(sdk): add the DashPay tab to KotlinExampleApp (migration K3)#4030
feat(sdk): add the DashPay tab to KotlinExampleApp (migration K3)#4030shumkov wants to merge 5 commits into
Conversation
platform_wallet_build_auto_accept_qr and platform_wallet_send_contact_request_from_qr, first consumed by the K3 DashPay tab (profile QR display + scan-to-send in AddContact) — the last two of the migration's bridged exports. Kotlin surface: Dashpay.buildAutoAcceptQr / sendContactRequestFromQr (returns a ContactRequestRef). QR generation/parsing/proof crypto is entirely Rust-side (auto_accept.rs); the app only renders/scans the URI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ce A) Restructure the KotlinExampleApp bottom tabs to make DashPay first-class, mirroring the iOS ContentView.swift RootTab (SYNC, WALLETS, IDENTITIES, DASHPAY, SETTINGS), and land the device-local contact-meta + avatar infra the DashPay screens (slices B/C) build on. UI hub itself is a placeholder here. - Coil: add io.coil-kt:coil-compose (the app's one approved new dependency) for avatar loading — libs.versions.toml + app build.gradle.kts. - Navigation: RootTab CONTRACTS -> DASHPAY (route DashPayHome, testTag rootTab.dashpay, Group icon, "DashPay" label). Contracts is demoted into Settings' Platform section (first row, testTag settings.contracts, navigating the shared ContractsHome route) exactly as iOS OptionsView hosts it. Register a placeholder DashPayTabScreen (testTag dashpay.tab.root) so navigation compiles; slice B replaces it. - Retire FriendsScreen: delete the screen, its Friends route, and repoint its only entry point (IdentityDetailScreen's DashPay row) to the DashPay tab. FriendsView.swift was deleted upstream. - DashPayContactMeta port (<- DashPayContactMeta.swift): SharedPreferences- backed DashPayContactMetaStore with the same key shape (dashpay.meta.<field>.<network>.<owner>.<contact>) and a version StateFlow for Compose invalidation, the dashPayContactDisplayName precedence helper, and a Coil-backed DashPayAvatar with an initials-circle fallback. - Lifecycle: start/stop DashPay sync in AppContainer.rebindWalletScopedServices alongside the address/shielded loops, matching iOS. - Update AppSmokeTest tab list (rootTab.contracts -> rootTab.dashpay). Build gate: :app:compileDebugKotlin + :sdk:compileDebugKotlin both pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…eens (K3 slice B) Port the four interactive DashPay screens the tab is built around, plus the JSON parsers and sync helpers the whole family shares. - DashPayJson.kt: data classes + org.json parsers for DashPayProfile, DpnsSearchResult, AccountBalance and DashPayPayment (slice C consumes the payment parser) — total/lenient, per-row failures skipped. - DashPayTabScreen.kt: replaces the slice-A placeholder. Wallet-backed identity picker (in-memory, resets on network switch), received-from- contacts balance (accountBalances typeTag 12, re-read after each sweep), the seedless-unlock banner off dashPayUnlockStatus (seed-mismatch / draining / pending-account-builds → unlockWalletFromKeystore), and EntityRow sections navigating to the per-section routes. Pull-to-refresh and a toolbar refresh both run dashPaySyncNow(). - ContactsScreen.kt: established-contacts (both-direction join, hidden excluded) from Room, searchable, avatar + display-name precedence, Hidden recovery link, pull-to-refresh. - ContactRequestsScreen.kt: incoming (Accept via acceptIncomingRequest / Ignore via ignoreContactSender, per-row in-flight + inline error + optimistic-removal overlay) and outgoing pending; incoming avatars are never loaded (IP-leak avoidance). - AddContactScreen.kt: 300 ms-debounced DPNS search, paste-id (32-byte base58 gate), preview card, optional account label, collision dialog (Accept vs Continue anyway), and scan-to-send via sendContactRequestFromQr. - DashPaySyncHelpers.kt: attachOrStartSync / kickDashPaySync. - Routes.kt + AppNavHost.kt: DashPay child routes + graph wiring. - Stub screens (ContactDetail / Profile / Ignored / Hidden) with fixed signatures + routes so the hub navigates end-to-end now; K3 slice C replaces their bodies. Structural deviation from iOS: the hub navigates to per-section routes rather than embedding a Contacts/Requests segmented control, and the cross-screen optimistic-sent overlay is dropped (Add Contact is its own route). Signing uses the manager's signer + mnemonic-resolver handles directly (the retired FriendsScreen's approach). Build gate: :app:compileDebugKotlin passes. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… parity (K3 slice C) Fill in the four stub screens and add the payment sheet, completing the DashPay tab port; delete the stub helper and rewrite PARITY for Views/DashPay/. - ContactDetailScreen: header (cached profile + DIP-15 account label), Send Dash (opens the payment sheet, disabled on broken channel), Room-driven payment history via the durable refreshDashPayPayments → observePayments path (sorted newest-first client-side), and alias/note editors + hide toggle via setContactInfo — surfacing the DEFERRED_UNTIL_TWO_CONTACTS / SKIPPED_WATCH_ONLY publish notices. - SendDashPayPaymentSheet (new): amount (decimal DASH → duffs) + balance check, sendPayment → txid (txidDisplayHex reversed-hex, added to the meta file), then always refreshDashPayPayments (the payment-durability invariant). No memo field (matches iOS); Balance.confirmed used as spendable. Hosted as a ModalBottomSheet from ContactDetail. - DashPayProfileScreen: read-only display + DIP-15 auto-accept QR (buildAutoAcceptQr rendered with the ZXing generateQrBitmap helper), plus an inline editor calling createOrUpdateProfile (doCreate when no profile exists). - IgnoredContactsScreen / HiddenContactsScreen: observeIgnoredSenders + unignoreContactSender / client-side hidden grouping + unhide (setContactInfo displayHidden=false, preserving alias/note). - Delete DashPayStub.kt (no stub remains). - androidTest/DashPayTabUITest.kt: ports the network-free launch-and-render flows from DashPayTabUITests.swift, adapted for the hub nav-section deviation. - PARITY.md: new Views/DashPay/ section (one row per view + deviations); FriendsView row retired; totals updated (ported 87 → 97). Gates: :app:compileDebugKotlin, :app:compileDebugAndroidTestKotlin, :sdk:testDebugUnitTest all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two-lens review (Compose concurrency + Swift parity) fixes on the DashPay tab. No behavior regressions; gates green. MUST-FIX - M1 payment sheet: wrap the send + post-broadcast bookkeeping (txid, onSent → durable refreshDashPayPayments) in withContext(NonCancellable), and block interactive dismissal of the host ModalBottomSheet while a send is in flight (sheetState confirmValueChange + guarded onDismissRequest via a new onSendingChange callback). Prevents a swipe-dismiss-mid-send from losing the confirmation/refresh after the (uncancellable) broadcast → double-payment risk. SHOULD-FIX - S1: register a NativeCleaner GC backstop on ContactRequestRef / EstablishedContactRef (matching Sdk.kt) so a cancellation-dropped ref can't leak its native handle for the process lifetime. - S2: drop ContactRequestsScreen's blanket LaunchedEffect(isSyncing) overlay wipe; the rows-driven prune already scopes removal to the action's own change, so an unrelated sweep no longer flashes accepted/ignored rows back. - S3: make DashPayContactMetaStore a single shared instance on AppContainer; the five screens now read it, so a write's version bump invalidates every reader (a per-screen instance only recomposed its own creator). - S4: guard the unlock banner with a local isUnlocking (disable the button while in flight) so a double-tap can't stack concurrent unlockWalletFromKeystore. - Parity S1: Dashpay.buildAutoAcceptQr username String? → non-optional String (Swift parity; the FFI check_ptr!-rejects null so the default was a latent throw); corrected the DashpayNative.kt + rs-unified-sdk-jni JNI doc claims. NOTES folded in: N1 (unconditional composable calls in UnlockBanner), N2 (remember the fallback dashPaySyncIsSyncing flow), N6 (prune Ignored/Hidden overlays against Room like Requests), N7 (encode the profile QR off the main thread via produceState). PARITY.md N7: added dashpay.profileHeader.setup to the not-reproduced tags + noted the AlertDialog alias/note editors. Gates: cargo check -p rs-unified-sdk-jni + :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :sdk:compileDebugKotlin :sdk:testDebugUnitTest all pass. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
|
✅ Review complete (commit 78f3627) |
There was a problem hiding this comment.
Code Review
Source: reviewers = claude ffi-engineer opus; claude general opus; codex ffi-engineer gpt-5.5; codex general gpt-5.5; verifier = claude opus; specialists = ffi-engineer; policy gate = review_policy.enforce_backport_prereq_policy.
K3 lands the DashPay tab, 10 supporting Compose screens, and two QR JNI bridges. Two K3 defects need addressing: (1) the post-send refreshDashPayPayments runs on ContactDetailScreen's rememberCoroutineScope even though the send itself is NonCancellable, so the durability-refresh invariant the PR advertises can still be lost if composition leaves mid-send; (2) the DashPay tab's eligible filter omits Swift's !isLocal check and exposes pending-registration identities to real state-transition flows. Additionally: HiddenContactsScreen.unhide discards the ContactInfoPublishOutcome (DEFERRED/WATCH_ONLY paths regress silently), an unused alias/note/hidden accessor block on DashPayContactMetaStore, and a small misleading error message on the QR JNI. FFI/JNI boundary and Cleaner-backed handle destruction are clean.
🔴 2 blocking | 🟡 1 suggestion(s) | 💬 2 nitpick(s)
Additional Verified Findings
-
[NITPICK] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt:40-104: DashPayContactMetaStore setAlias / setNote / setHidden / isHidden / note are dead API
setAlias,setNote,setHidden,isHidden, and thenote(...)reader are defined onDashPayContactMetaStorebut have no callers anywhere in KotlinExampleApp — grep confirms onlyalias(...)(used by ContactRequestsScreen for pending-incoming rows) anddpnsHint/setDpnsHintare load-bearing. Alias / note / hidden are now round-tripped throughsetContactInfo→ Room (contactAlias,contactNote,contactHiddenon the contact-request rows). Either delete the unused accessors or document the device-localdpnsHintvs Platform-synced alias/note/hidden divide so a future contributor doesn't reach formetaStore.setAliasin ContactDetail. -
[NITPICK] packages/rs-unified-sdk-jni/src/dashpay.rs:874-877: sendContactRequestFromQr throws "uri must not be null" for non-null URIs containing interior NUL bytes
opt_jstring_to_cstringreturnsNonefor two distinct failure modes:env.get_string(...)failing, orCString::new(...)rejecting the string because it contains an interior NUL byte. The thrown message"uri must not be null"misdiagnoses the second case — the URI string was received; it just wasn't marshalable as a C string. A QR scanner's payload won't hit this, but a paste / test injection will surface a misleading error. Use e.g."uri must not be null or contain embedded null bytes".
🤖 Prompt for all review comments with AI agents
These findings are from an automated code review. Verify each finding against the current code and only fix it if needed.
In `packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt`:
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt:352: Post-send payment refresh still runs on a cancellable Compose scope
`SendDashPayPaymentSheet.send()` correctly wraps the broadcast + `onSent()` in `withContext(NonCancellable)`, with the comment explicitly stating that losing the durability refresh would "invite a double-send." But `onSent = { refreshPayments() }` calls `ContactDetailScreen.refreshPayments`, which does `scope.launch { m.refreshDashPayPayments(wid, idBytes) }` on the composable's `rememberCoroutineScope`. If ContactDetailScreen leaves composition while the send is in flight (system back after the sheet's confirmValueChange stops blocking, config change, process death handoff, or the `delay(1500); onClose()` tail window), the parent scope is cancelled — so the new `scope.launch` for the refresh is dead-on-arrival even though the caller thinks it's inside NonCancellable. The net result contradicts the stated invariant: the payment is broadcast but its Room upsert doesn't run through the durability path. Either run `refreshDashPayPayments` directly inside the sheet's NonCancellable block (before `onSent()` returns), or route it through a scope tied to the wallet manager / application, not the screen.
In `packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt`:
- [BLOCKING] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt:102-106: Eligible-identity filter is missing the `!isLocal` gate (parity with Swift)
Swift's `eligibleIdentities` (DashPayTabView.swift:95-100) requires `!identity.isLocal` before considering an identity DashPay-usable. The Kotlin filter only checks `walletId != null && walletsMap.containsKey(...)` and the underlying DAO (`observeWalletOwnedByNetwork`) does not filter isLocal either. `IdentityEntity.isLocal` defaults to `true`, so any wallet-created-but-unregistered identity (pending registration) makes it through and gets the full DashPay hub. That identity is not on Platform yet, so contact-request sends, profile creation, and setContactInfo will all fail against real errors — the correct UX is the "No identities yet" empty state until registration completes. Add the `!isLocal` filter here.
In `packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt`:
- [SUGGESTION] packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt:107-132: unhide() discards ContactInfoPublishOutcome — DEFERRED / SKIPPED_WATCH_ONLY unhides regress silently
`unhide` calls `w.dashpay.setContactInfo(..., displayHidden = false, ...)` and unconditionally adds the contact hex to `removedOverlayIds`, ignoring the returned `ContactInfoPublishOutcome`. `ContactDetailScreen.saveContactInfo` handles the same outcome carefully and surfaces `DEFERRED_UNTIL_TWO_CONTACTS` / `SKIPPED_WATCH_ONLY` via `publishNotice`. Here, when the outcome is DEFERRED or SKIPPED_WATCH_ONLY the Platform-side `contactInfo.displayHidden` is not flipped, so a subsequent Platform sync sweep can restore `contactHidden = true` on the Room row. `removedOverlayIds` is per-composition, so after navigate-away-and-back the contact reappears in the Hidden list without any explanation. Watch-only identities in particular cannot unhide from this screen and the failure mode is completely silent. Mirror ContactDetail: capture the outcome and render a row-level notice / inline error for non-PUBLISHED outcomes.
| contactDisplayName = displayName, | ||
| contactDpnsName = dpnsHint, | ||
| onSendingChange = { paymentSending = it }, | ||
| onSent = { refreshPayments() }, |
There was a problem hiding this comment.
🔴 Blocking: Post-send payment refresh still runs on a cancellable Compose scope
SendDashPayPaymentSheet.send() correctly wraps the broadcast + onSent() in withContext(NonCancellable), with the comment explicitly stating that losing the durability refresh would "invite a double-send." But onSent = { refreshPayments() } calls ContactDetailScreen.refreshPayments, which does scope.launch { m.refreshDashPayPayments(wid, idBytes) } on the composable's rememberCoroutineScope. If ContactDetailScreen leaves composition while the send is in flight (system back after the sheet's confirmValueChange stops blocking, config change, process death handoff, or the delay(1500); onClose() tail window), the parent scope is cancelled — so the new scope.launch for the refresh is dead-on-arrival even though the caller thinks it's inside NonCancellable. The net result contradicts the stated invariant: the payment is broadcast but its Room upsert doesn't run through the durability path. Either run refreshDashPayPayments directly inside the sheet's NonCancellable block (before onSent() returns), or route it through a scope tied to the wallet manager / application, not the screen.
source: ['codex']
| val eligible = remember(allWalletOwned, walletsMap) { | ||
| allWalletOwned | ||
| .filter { it.walletId != null && walletsMap.containsKey(it.walletId!!.toHex()) } | ||
| .sortedBy { it.createdAt.time } | ||
| } |
There was a problem hiding this comment.
🔴 Blocking: Eligible-identity filter is missing the !isLocal gate (parity with Swift)
Swift's eligibleIdentities (DashPayTabView.swift:95-100) requires !identity.isLocal before considering an identity DashPay-usable. The Kotlin filter only checks walletId != null && walletsMap.containsKey(...) and the underlying DAO (observeWalletOwnedByNetwork) does not filter isLocal either. IdentityEntity.isLocal defaults to true, so any wallet-created-but-unregistered identity (pending registration) makes it through and gets the full DashPay hub. That identity is not on Platform yet, so contact-request sends, profile creation, and setContactInfo will all fail against real errors — the correct UX is the "No identities yet" empty state until registration completes. Add the !isLocal filter here.
| val eligible = remember(allWalletOwned, walletsMap) { | |
| allWalletOwned | |
| .filter { it.walletId != null && walletsMap.containsKey(it.walletId!!.toHex()) } | |
| .sortedBy { it.createdAt.time } | |
| } | |
| val eligible = remember(allWalletOwned, walletsMap) { | |
| allWalletOwned | |
| .filter { !it.isLocal && it.walletId != null && walletsMap.containsKey(it.walletId!!.toHex()) } | |
| .sortedBy { it.createdAt.time } | |
| } |
source: ['codex']
| fun unhide(contact: HiddenContactItem) { | ||
| val m = manager ?: return | ||
| val w = wallet ?: return | ||
| val hex = contact.contactId.toHex() | ||
| rowErrors = rowErrors - hex | ||
| inFlightIds = inFlightIds + hex | ||
| scope.launch { | ||
| try { | ||
| w.dashpay.setContactInfo( | ||
| identityId = ownerBytes, | ||
| contactId = contact.contactId, | ||
| alias = contact.alias, | ||
| note = contact.note, | ||
| displayHidden = false, | ||
| signerHandle = m.signerHandle, | ||
| coreSignerHandle = m.mnemonicResolverHandle, | ||
| ) | ||
| removedOverlayIds = removedOverlayIds + hex | ||
| kickDashPaySync(scope, m) | ||
| } catch (e: Exception) { | ||
| rowErrors = rowErrors + (hex to "Unhide failed: ${e.message ?: "unknown error"}") | ||
| } finally { | ||
| inFlightIds = inFlightIds - hex | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
🟡 Suggestion: unhide() discards ContactInfoPublishOutcome — DEFERRED / SKIPPED_WATCH_ONLY unhides regress silently
unhide calls w.dashpay.setContactInfo(..., displayHidden = false, ...) and unconditionally adds the contact hex to removedOverlayIds, ignoring the returned ContactInfoPublishOutcome. ContactDetailScreen.saveContactInfo handles the same outcome carefully and surfaces DEFERRED_UNTIL_TWO_CONTACTS / SKIPPED_WATCH_ONLY via publishNotice. Here, when the outcome is DEFERRED or SKIPPED_WATCH_ONLY the Platform-side contactInfo.displayHidden is not flipped, so a subsequent Platform sync sweep can restore contactHidden = true on the Room row. removedOverlayIds is per-composition, so after navigate-away-and-back the contact reappears in the Hidden list without any explanation. Watch-only identities in particular cannot unhide from this screen and the failure mode is completely silent. Mirror ContactDetail: capture the outcome and render a row-level notice / inline error for non-PUBLISHED outcomes.
source: ['claude']
| class DashPayContactMetaStore(context: Context) { | ||
|
|
||
| private val prefs = context.getSharedPreferences("dashpay_contact_meta", Context.MODE_PRIVATE) | ||
|
|
||
| /** Bumped on every write so observing composables recompute reads. */ | ||
| private val _version = MutableStateFlow(0) | ||
| val version: StateFlow<Int> = _version.asStateFlow() | ||
|
|
||
| // ── Alias (local display-name override) ────────────────────────────── | ||
|
|
||
| fun alias(network: Network, owner: ByteArray, contact: ByteArray): String? = | ||
| nonEmpty(prefs.getString(key("alias", network, owner, contact), null)) | ||
|
|
||
| fun setAlias(alias: String?, network: Network, owner: ByteArray, contact: ByteArray) { | ||
| write(nonEmpty(alias), key("alias", network, owner, contact)) | ||
| } | ||
|
|
||
| // ── Note ───────────────────────────────────────────────────────────── | ||
|
|
||
| fun note(network: Network, owner: ByteArray, contact: ByteArray): String? = | ||
| nonEmpty(prefs.getString(key("note", network, owner, contact), null)) | ||
|
|
||
| fun setNote(note: String?, network: Network, owner: ByteArray, contact: ByteArray) { | ||
| write(nonEmpty(note), key("note", network, owner, contact)) | ||
| } | ||
|
|
||
| // ── Hidden ─────────────────────────────────────────────────────────── | ||
|
|
||
| fun isHidden(network: Network, owner: ByteArray, contact: ByteArray): Boolean = | ||
| prefs.getBoolean(key("hidden", network, owner, contact), false) | ||
|
|
||
| fun setHidden(hidden: Boolean, network: Network, owner: ByteArray, contact: ByteArray) { | ||
| prefs.edit().putBoolean(key("hidden", network, owner, contact), hidden).apply() | ||
| _version.value += 1 | ||
| } | ||
|
|
||
| // ── DPNS hint ──────────────────────────────────────────────────────── | ||
|
|
||
| /** | ||
| * DPNS label observed when the contact was added via username search. | ||
| * Display-precedence fallback only — contacts' DPNS labels aren't | ||
| * persisted in Room (only managed identities' are), so this hint is | ||
| * "the data available" for the contact rows. | ||
| */ | ||
| fun dpnsHint(network: Network, owner: ByteArray, contact: ByteArray): String? = | ||
| nonEmpty(prefs.getString(key("dpnsHint", network, owner, contact), null)) | ||
|
|
||
| fun setDpnsHint(name: String?, network: Network, owner: ByteArray, contact: ByteArray) { | ||
| write(nonEmpty(name), key("dpnsHint", network, owner, contact)) | ||
| } | ||
|
|
||
| // ── Helpers ────────────────────────────────────────────────────────── | ||
|
|
||
| private fun key(field: String, network: Network, owner: ByteArray, contact: ByteArray): String = | ||
| "dashpay.meta.$field.${network.ffiValue}.${owner.toHex()}.${contact.toHex()}" | ||
|
|
||
| private fun write(value: String?, key: String) { | ||
| prefs.edit().apply { | ||
| if (value != null) putString(key, value) else remove(key) | ||
| }.apply() | ||
| _version.value += 1 | ||
| } | ||
|
|
||
| private fun nonEmpty(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() } | ||
| } |
There was a problem hiding this comment.
💬 Nitpick: DashPayContactMetaStore setAlias / setNote / setHidden / isHidden / note are dead API
setAlias, setNote, setHidden, isHidden, and the note(...) reader are defined on DashPayContactMetaStore but have no callers anywhere in KotlinExampleApp — grep confirms only alias(...) (used by ContactRequestsScreen for pending-incoming rows) and dpnsHint/setDpnsHint are load-bearing. Alias / note / hidden are now round-tripped through setContactInfo → Room (contactAlias, contactNote, contactHidden on the contact-request rows). Either delete the unused accessors or document the device-local dpnsHint vs Platform-synced alias/note/hidden divide so a future contributor doesn't reach for metaStore.setAlias in ContactDetail.
source: ['claude']
| let Some(uri_c) = opt_jstring_to_cstring(env, &uri) else { | ||
| crate::support::throw_sdk_exception(env, 1, "uri must not be null"); | ||
| return 0; | ||
| }; |
There was a problem hiding this comment.
💬 Nitpick: sendContactRequestFromQr throws "uri must not be null" for non-null URIs containing interior NUL bytes
opt_jstring_to_cstring returns None for two distinct failure modes: env.get_string(...) failing, or CString::new(...) rejecting the string because it contains an interior NUL byte. The thrown message "uri must not be null" misdiagnoses the second case — the URI string was received; it just wasn't marshalable as a C string. A QR scanner's payload won't hit this, but a paste / test injection will surface a misleading error. Use e.g. "uri must not be null or contain embedded null bytes".
source: ['claude']
Issue being fixed or feature implemented
Milestone K3 — the final milestone of the DashPay Swift→Kotlin migration (
docs/dashpay/KOTLIN_MIGRATION_SPEC.md§K3): the first-class DashPay tab with all 10Views/DashPay/screens, the navigation restructure, and the parity bookkeeping. Stacked on #4027 (K2), which stacks on #4025 (K1). With this PR the migration's three milestones are complete.What was done?
Navigation restructure (mirrors Swift
ContentView.swift):RootTabis now SYNC / WALLETS / IDENTITIES / DASHPAY / SETTINGS — the Contracts tab is demoted into Settings → Platform (kept fully functional), and the staleFriendsScreen(port of a Swift view #3841 deleted) is retired with its entry points repointed at the DashPay tab.The DashPay tab — all 10 views ported (one Compose file per Swift file, Swift sources cited in KDoc, iOS accessibility identifiers reused verbatim as testTags):
DashPayTabScreen(hub: identity picker, pull-to-refresh →dashPaySyncNow, per-account balances, the seedless-unlock banner overdashPayUnlockStatuswith in-flight guard),ContactsScreen,ContactRequestsScreen(accept/ignore with Room-scoped optimistic overlays),AddContactScreen(debounced wallet-scoped DPNS search, raw-id entry, QR scan-to-send),ContactDetailScreen(cached contact profile, alias/note/hide viasetContactInfosurfacing the DIP-15ContactInfoPublishOutcome, payment history via the durable refresh→Room path,paymentChannelBrokengating),SendDashPayPaymentSheet(NonCancellable money path + dismissal blocked while sending + refresh-after-send durability invariant),DashPayProfileScreen(view/edit + DIP-15 auto-accept QR),IgnoredContactsScreen,HiddenContactsScreen, andDashPayContactMeta(shared meta store singleton + Coil avatar with initials fallback; incoming-request avatars deliberately never load remote images).platform_wallet_build_auto_accept_qr/platform_wallet_send_contact_request_from_qr(QR crypto stays entirely Rust-side).AppContainer(the manager auto-unlocks + status-polls fromloadPersistedWallets, per K2).coil-compose(avatar loading) — the migration's only third-party addition.Parity bookkeeping: full
PARITY.mdrewrite — newViews/DashPay/section (one row per view), FriendsView marked retired, totals corrected (97 views), and every material deviation documented (hub uses nav-section rows instead of a segmented control; in-memory identity selection; optimistic-sent overlay dropped; alias/note editors are AlertDialogs; the dropped iOS-only testTags listed).Code-reviewed by two independent lenses (Compose state/concurrency; Swift-parity + doctrine incl. the QR bridges): 1 must-fix (payment-sheet dispose-mid-send losing post-broadcast bookkeeping — a double-payment risk Swift doesn't have) and 5 should-fixes found and folded, including
NativeCleanerGC backstops on the contact-request handle wrappers and thebuildAutoAcceptQrsignature parity fix.How Has This Been Tested?
DashPayTabUITest, androidTest): the network-free render-state flows fromDashPayTabUITests.swift, adapted for the documented hub deviation. Compile-gated locally; executed by the CI emulator in this PR.:app:compileDebugKotlin,:app:compileDebugAndroidTestKotlin,:sdk:compileDebugKotlin,:sdk:testDebugUnitTest,cargo check/clippy/fmton the JNI crate.QA_TESTCASES_SPEC.mdflows,-Ptestnet=true) is device/testnet-bound and remains to be run against this stack — recommended before merging the K-series into feat(sdk): add Kotlin SDK and KotlinExampleApp (Android port of SwiftExampleApp) #3999's branch.Breaking Changes
None externally. Within the unreleased KotlinExampleApp: the Contracts tab moved into Settings and the
Friendsroute +friends.*testTags are gone (superseded by the DashPay tab). Within the unreleased Kotlin SDK:Dashpay.buildAutoAcceptQrtakes a non-optionalusername: String(Swift parity).Checklist:
For repository code-owners and collaborators only
🤖 Generated with Claude Code