Skip to content

feat(sdk): add the DashPay tab to KotlinExampleApp (migration K3)#4030

Open
shumkov wants to merge 5 commits into
feat/kotlin-sdk-dashpay-k2from
feat/kotlin-sdk-dashpay-k3
Open

feat(sdk): add the DashPay tab to KotlinExampleApp (migration K3)#4030
shumkov wants to merge 5 commits into
feat/kotlin-sdk-dashpay-k2from
feat/kotlin-sdk-dashpay-k3

Conversation

@shumkov

@shumkov shumkov commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

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 10 Views/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): RootTab is now SYNC / WALLETS / IDENTITIES / DASHPAY / SETTINGS — the Contracts tab is demoted into Settings → Platform (kept fully functional), and the stale FriendsScreen (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 over dashPayUnlockStatus with 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 via setContactInfo surfacing the DIP-15 ContactInfoPublishOutcome, payment history via the durable refresh→Room path, paymentChannelBroken gating), SendDashPayPaymentSheet (NonCancellable money path + dismissal blocked while sending + refresh-after-send durability invariant), DashPayProfileScreen (view/edit + DIP-15 auto-accept QR), IgnoredContactsScreen, HiddenContactsScreen, and DashPayContactMeta (shared meta store singleton + Coil avatar with initials fallback; incoming-request avatars deliberately never load remote images).
  • Bridged the final two exports: platform_wallet_build_auto_accept_qr / platform_wallet_send_contact_request_from_qr (QR crypto stays entirely Rust-side).
  • Lifecycle: the DashPay sweep starts/stops with the wallet-scoped services in AppContainer (the manager auto-unlocks + status-polls from loadPersistedWallets, per K2).
  • New dependency: coil-compose (avatar loading) — the migration's only third-party addition.

Parity bookkeeping: full PARITY.md rewrite — new Views/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 NativeCleaner GC backstops on the contact-request handle wrappers and the buildAutoAcceptQr signature parity fix.

How Has This Been Tested?

  • Compose UI tests (DashPayTabUITest, androidTest): the network-free render-state flows from DashPayTabUITests.swift, adapted for the documented hub deviation. Compile-gated locally; executed by the CI emulator in this PR.
  • Gates local-green across every commit: :app:compileDebugKotlin, :app:compileDebugAndroidTestKotlin, :sdk:compileDebugKotlin, :sdk:testDebugUnitTest, cargo check/clippy/fmt on the JNI crate.
  • ⚠ Honest gaps: the review's concurrency fixes have no instrumented coverage (the UI test is render-only), and the spec's end-to-end send→accept→pay testnet UAT (QA_TESTCASES_SPEC.md flows, -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 Friends route + friends.* testTags are gone (superseded by the DashPay tab). Within the unreleased Kotlin SDK: Dashpay.buildAutoAcceptQr takes a non-optional username: String (Swift parity).

Checklist:

  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have added or updated relevant unit/integration/functional/e2e tests
  • I have added "!" to the title and described breaking changes in the corresponding section if my code contains any
  • I have made corresponding changes to the documentation if needed

For repository code-owners and collaborators only

  • I have assigned this pull request to a milestone

🤖 Generated with Claude Code

shumkov and others added 5 commits July 6, 2026 22:20
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>
@shumkov shumkov requested a review from QuantumExplorer as a code owner July 6, 2026 16:39
@coderabbitai

coderabbitai Bot commented Jul 6, 2026

Copy link
Copy Markdown
Contributor

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 5aff883d-cc96-4487-8af0-a6f73869e2cb

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/kotlin-sdk-dashpay-k3

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@thepastaclaw

thepastaclaw commented Jul 6, 2026

Copy link
Copy Markdown
Collaborator

✅ Review complete (commit 78f3627)

@thepastaclaw thepastaclaw left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 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.

  • [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_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".

🤖 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() },

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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']

Comment on lines +102 to +106
val eligible = remember(allWalletOwned, walletsMap) {
allWalletOwned
.filter { it.walletId != null && walletsMap.containsKey(it.walletId!!.toHex()) }
.sortedBy { it.createdAt.time }
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.

Suggested change
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']

Comment on lines +107 to +132
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
}
}
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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']

Comment on lines +40 to +104
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() }
}

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 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']

Comment on lines +874 to +877
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;
};

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💬 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']

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants