Skip to content

Comments

Add wallet descriptor management#171

Open
wksantiago wants to merge 14 commits intomainfrom
Add-wallet-descriptor
Open

Add wallet descriptor management#171
wksantiago wants to merge 14 commits intomainfrom
Add-wallet-descriptor

Conversation

@wksantiago
Copy link
Contributor

@wksantiago wksantiago commented Feb 24, 2026

Summary

  • Add wallet descriptor screen for proposing, approving, exporting, and deleting multisig wallet descriptors
  • Security hardening: atomic state mutations, account switch cleanup, input validation
  • UI fixes: FlowRow for network chip layout, index-based dividers, dialog race fix

Test Results

Tested on-device with 2x Pixel 9a + keep-desktop (2-of-3 FROST group, signet):

# Test Result
1 Unit tests PASS
2 Install APK PASS
3 Propose descriptor (signet) PASS
4 3-way coordination (propose → contribute → finalize → ack) PASS
5 Export (Sparrow format) PASS
6 Export (Raw format) PASS
7 Delete descriptor + confirm cleanup PASS
8 Account switching (no stale state) PASS

Device Configuration

Key Findings

  • Full 3-way descriptor coordination works end-to-end over Nostr relays
  • All 3 shares contribute and ACK correctly, session transitions to Complete
  • Descriptor stored and displayed with correct network/timestamp
  • Export copies descriptor to clipboard in both Sparrow and raw formats
  • Delete shows confirmation dialog, removes descriptor, count updates to 0
  • Account switch clears all session state and descriptors cleanly

Summary by CodeRabbit

Release Notes

  • New Features

    • Added Wallet Descriptors management card to the Apps section, displaying descriptor count and providing access to management features.
    • New dedicated screen for managing wallet descriptors with support for proposing, exporting, and deleting descriptors, plus session-based workflow handling.
  • Tests

    • Added comprehensive test suite validating descriptor session state transitions and lifecycle management.

@coderabbitai
Copy link

coderabbitai bot commented Feb 24, 2026

Walkthrough

Adds a Wallet Descriptor feature: a new WalletDescriptorCard UI, WalletDescriptorScreen composable, DescriptorSessionManager (state, callbacks, pending proposals), PollResult descriptorCount wiring, account-switch cleanup, and unit tests for the session manager.

Changes

Cohort / File(s) Summary
UI Card
app/src/main/kotlin/io/privkey/keep/ConnectionCards.kt
New WalletDescriptorCard(descriptorCount, onClick) composable rendering descriptor header and count/manage action; clickable and styled consistently with existing cards.
Main app wiring
app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt, app/src/main/kotlin/io/privkey/keep/MainActivity.kt
Register DescriptorSessionManager callbacks on startup; call DescriptorSessionManager.clearAll() on account switch; extend PollResult with descriptorCount; thread descriptorCount and onWalletDescriptorClick through MainScreen/AppsTab/HomeTab; show WalletDescriptorScreen when triggered.
Descriptor feature module
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
New WalletDescriptorScreen composable; DescriptorSessionState sealed class; DescriptorSessionManager object exposing StateFlows, createCallbacks(), pending proposal management, and lifecycle functions; UI cards/dialogs for propose/export/delete and clipboard/export flows.
Tests
app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt
New unit tests validating session state transitions, per-network pending-proposal tracking, proposal removal, reset semantics, full propose→complete and reject flows, and callback registration behavior.

Sequence Diagrams

sequenceDiagram
    actor User
    participant UI as "WalletDescriptorScreen\nrgba(54,162,235,0.5)"
    participant Backend as "KeepMobile\nrgba(75,192,192,0.5)"
    participant Manager as "DescriptorSessionManager\nrgba(255,159,64,0.5)"

    User->>UI: Open Wallet Descriptor screen
    UI->>Backend: walletDescriptorList()
    Backend-->>UI: descriptors
    UI->>Manager: register callbacks / observe state
    Manager-->>UI: emit current state
Loading
sequenceDiagram
    actor User
    participant UI as "WalletDescriptorScreen\nrgba(54,162,235,0.5)"
    participant Backend as "KeepMobile\nrgba(75,192,192,0.5)"
    participant Manager as "DescriptorSessionManager\nrgba(255,159,64,0.5)"

    User->>UI: Propose new descriptor
    UI->>Backend: proposeDescriptor()
    Backend-->>Manager: onProposed(sessionId)
    Manager->>Manager: state = Proposed(sessionId)
    Manager-->>UI: state update (pending)
    User->>UI: Approve / contribute
    UI->>Backend: approveDescriptor() / contribute
    Backend-->>Manager: onContributionNeeded/onContributed
    Manager-->>UI: state updates (ContributionNeeded/Contributed)
    Backend-->>Manager: onComplete(sessionId, external, internal)
    Manager->>Manager: state = Complete(...)
    Manager-->>UI: emit completion (enable export/delete)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Possibly related issues

Suggested reviewers

  • kwsantiago

Poem

🐰 I hopped in to count each descriptor bright,

Proposals bounced and sessions took flight,
Callbacks hummed while pending lists grew,
Approve, export, complete — a tidy view,
A rabbit's cheer for multisig delight.

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'Add wallet descriptor management' directly and clearly summarizes the main change: introduction of wallet descriptor management functionality across the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
  • 📝 Generate docstrings (stacked PR)
  • 📝 Generate docstrings (commit on current branch)
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch Add-wallet-descriptor

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 and usage tips.

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

52-100: Avoid duplicate pending proposals if callbacks repeat.

Defensive dedupe by sessionId prevents duplicate rows and repeated actions when the backend replays events.

♻️ Proposed dedupe safeguard
         override fun onContributionNeeded(proposal: DescriptorProposal) {
-            _pendingProposals.update { it + proposal }
+            _pendingProposals.update { existing ->
+                if (existing.any { it.sessionId == proposal.sessionId }) existing else existing + proposal
+            }
             _state.value = DescriptorSessionState.ContributionNeeded(proposal)
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 52 - 100, The pending proposals list can receive duplicates when
callbacks repeat; update the onContributionNeeded handler in
DescriptorSessionManager (inside createCallbacks) to dedupe by sessionId before
appending: instead of _pendingProposals.update { it + proposal } only add the
proposal when no existing item has the same DescriptorProposal.sessionId (e.g.,
_pendingProposals.update { list -> if (list.any { it.sessionId ==
proposal.sessionId }) list else list + proposal }); keep removePendingProposal,
clearAll and other state transitions unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 102-266: The refreshDescriptors function currently calls
keepMobile.walletDescriptorList() without error handling, which can crash the
UI; update refreshDescriptors to wrap the network call in runCatching (or
try/catch) inside the existing scope.launch/withContext(Dispatchers.IO) block,
only assign to the descriptors state on success, and handle failures by showing
a Toast (or logging) instead of throwing; reference the refreshDescriptors
function, the descriptors mutable state, and keepMobile.walletDescriptorList to
locate where to add runCatching/onFailure handling.

---

Nitpick comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 52-100: The pending proposals list can receive duplicates when
callbacks repeat; update the onContributionNeeded handler in
DescriptorSessionManager (inside createCallbacks) to dedupe by sessionId before
appending: instead of _pendingProposals.update { it + proposal } only add the
proposal when no existing item has the same DescriptorProposal.sessionId (e.g.,
_pendingProposals.update { list -> if (list.any { it.sessionId ==
proposal.sessionId }) list else list + proposal }); keep removePendingProposal,
clearAll and other state transitions unchanged.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f26120f and 712ac4e.

📒 Files selected for processing (4)
  • app/src/main/kotlin/io/privkey/keep/ConnectionCards.kt
  • app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
app/src/main/kotlin/io/privkey/keep/MainActivity.kt (1)

403-428: ⚠️ Potential issue | 🟠 Major

walletDescriptorList() failure in the polling loop would halt all polling.

Line 413 adds keepMobile.walletDescriptorList().size to the polling block. If this call throws (e.g., network error), the entire repeat loop terminates because there's no try-catch around the polling body. This is a pre-existing fragility for all calls in this block, but descriptor list fetching (potentially a network call) increases the surface area.

Consider wrapping the descriptor count fetch independently so a failure doesn't halt peer/pending-count polling:

Proposed safeguard
                     val pc = if (h) keepMobile.getPendingRequests().size else 0
-                    val dc = if (h) keepMobile.walletDescriptorList().size else 0
+                    val dc = if (h) runCatching { keepMobile.walletDescriptorList().size }.getOrDefault(0) else 0
                     PollResult(h, s, a, k, p, pc, dc)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt` around lines 403 - 428,
The polling loop inside LaunchedEffect constructs a PollResult by calling
keepMobile.walletDescriptorList().size which, if it throws, will cancel the
entire repeat loop; change the descriptor count fetch to be isolated and
resilient—call walletDescriptorList() inside its own try/catch or runCatching
when building the PollResult (or compute dc separately before constructing
PollResult), and on failure return a safe fallback (e.g., previous
descriptorCount or 0) and log the error instead of letting the exception
propagate; ensure the rest of the PollResult values (hasShare, getPeers,
getPendingRequests, etc.) remain untouched so polling continues even if
walletDescriptorList() fails.
♻️ Duplicate comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

120-130: Previous review finding addressed — refreshDescriptors is now guarded with runCatching.

The error handling wraps the IO call and shows a toast on failure. Looks good.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 120 - 130, The previous issue has been addressed:
refreshDescriptors now wraps the IO call in runCatching and shows a toast on
failure; no further changes required—leave the refreshDescriptors function
(scope.launch { runCatching { withContext(Dispatchers.IO) {
keepMobile.walletDescriptorList() } }... }) and its descriptors assignment/toast
handling as-is.
🧹 Nitpick comments (4)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (3)

55-103: DescriptorSessionManager as a global singleton limits testability and multi-account scenarios.

A singleton object couples the state machine to global scope, making it harder to test in isolation (as noted in the test file) and impossible to run concurrent sessions for different accounts. Consider refactoring to a class instance managed by DI if multi-account or parallel descriptor flows become a requirement.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 55 - 103, DescriptorSessionManager is defined as a global object
which prevents isolated testing and multi-account/parallel sessions; refactor it
into a regular class (e.g., class DescriptorSessionManager) so state flows
(_state, _pendingProposals) and methods (createCallbacks, clearSessionState,
clearAll, removePendingProposal) become instance members, then register/provide
that class via your DI container or construct per-test/per-account; update all
call sites and tests to obtain an instance (instead of referencing the object)
and ensure createCallbacks returns callbacks bound to the instance so session
events modify the instance state rather than global state.

483-501: Redundant re-parsing on button click.

Lines 484–486 already parse and validate threshold/timelockMonths to compute valid, which disables the button. Lines 489–491 re-parse and re-validate on click. This is defensive, but the double work could be simplified by passing the pre-validated values directly.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 483 - 501, The confirm button currently parses threshold and
timelockMonths twice (once to compute valid and again inside the onClick), so
parse them once into local values (e.g., parsedThreshold and parsedTimelock)
outside the TextButton and use those for both the enabled check and the onClick;
update the confirmButton lambda (the TextButton in WalletDescriptorScreen) to
use the pre-parsed parsedThreshold/parsedTimelock and call onPropose(network,
listOf(RecoveryTierConfig(parsedThreshold, parsedTimelock))) directly, removing
the redundant re-parsing and duplicate range checks while keeping the same
validity constraints (1u..15u and 1u..120u).

62-89: Non-atomic dual-state mutation in callbacks.

In onContributionNeeded, onComplete, and onFailed, two separate StateFlows (_pendingProposals and _state) are updated sequentially. Between these two writes, an observer could see an inconsistent snapshot (e.g., proposals updated but state still stale). In practice this is low-risk since Compose batches recompositions, but worth noting if these callbacks can fire off the main thread.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 62 - 89, The callbacks update two separate StateFlows
(_pendingProposals and _state) non-atomically in onContributionNeeded,
onComplete and onFailed; protect these paired writes by serializing them with a
small mutex (e.g., declare a private val descriptorCallbacksMutex = Mutex()) and
wrap the paired updates inside descriptorCallbacksMutex.withLock { ... } so
onContributionNeeded does descriptorCallbacksMutex.withLock {
_pendingProposals.update { it + proposal }; _state.value =
DescriptorSessionState.ContributionNeeded(proposal) } and similarly wrap
removePendingProposal + _state.value assignments in onComplete and onFailed (and
update the removePendingProposal implementation to use the same mutex if it
mutates _pendingProposals) to ensure observers never see a partial state.
app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt (1)

12-17: Singleton-based test isolation relies on sequential execution.

Since DescriptorSessionManager is a global object, these tests are only safe when run sequentially. JUnit 4 does this by default within a single class, but if any other test class (or future parallel test runner) also interacts with DescriptorSessionManager, you could see flaky failures. Consider noting this constraint or, longer-term, refactoring DescriptorSessionManager to be injectable.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt`
around lines 12 - 17, Tests rely on the global DescriptorSessionManager
singleton which can cause cross-test or cross-class flakiness under parallel
execution; update the test to explicitly document this constraint and make the
expectation clear (add a comment above setup() referencing
DescriptorSessionManager.clearAll()), and as a longer-term fix refactor the
singleton into an injectable instance (replace the DescriptorSessionManager
object with a class/instance or introduce a provider interface and use
dependency injection in the code under test) so tests can create and inject
isolated instances instead of relying on the global clearAll().
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 225-241: The dialog is being closed regardless of success because
showProposeDialog = false is set outside the runCatching block; update the
ProposeDescriptorDialog onPropose handler so that you only set showProposeDialog
= false when keepMobile.walletDescriptorPropose succeeds (e.g., move the
assignment into the runCatching success path or use onSuccess), and optionally
preserve the input state on failure so the user doesn't need to re-enter data;
look for the onPropose lambda and the runCatching surrounding
keepMobile.walletDescriptorPropose to make this change.
- Around line 264-282: The delete dialog is always dismissed because
showDeleteConfirm = null is executed regardless of success; change the flow so
showDeleteConfirm is only cleared when the deletion actually succeeds (e.g.,
move the assignment into the success path of runCatching or call it inside
.onSuccess after keepMobile.walletDescriptorDelete and refreshDescriptors
complete), and leave the DeleteDescriptorDialog open on .onFailure while still
showing the Toast; update the handlers around DeleteDescriptorDialog,
runCatching, keepMobile.walletDescriptorDelete and refreshDescriptors
accordingly so failures do not close the dialog prematurely.

---

Outside diff comments:
In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt`:
- Around line 403-428: The polling loop inside LaunchedEffect constructs a
PollResult by calling keepMobile.walletDescriptorList().size which, if it
throws, will cancel the entire repeat loop; change the descriptor count fetch to
be isolated and resilient—call walletDescriptorList() inside its own try/catch
or runCatching when building the PollResult (or compute dc separately before
constructing PollResult), and on failure return a safe fallback (e.g., previous
descriptorCount or 0) and log the error instead of letting the exception
propagate; ensure the rest of the PollResult values (hasShare, getPeers,
getPendingRequests, etc.) remain untouched so polling continues even if
walletDescriptorList() fails.

---

Duplicate comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 120-130: The previous issue has been addressed: refreshDescriptors
now wraps the IO call in runCatching and shows a toast on failure; no further
changes required—leave the refreshDescriptors function (scope.launch {
runCatching { withContext(Dispatchers.IO) { keepMobile.walletDescriptorList() }
}... }) and its descriptors assignment/toast handling as-is.

---

Nitpick comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 55-103: DescriptorSessionManager is defined as a global object
which prevents isolated testing and multi-account/parallel sessions; refactor it
into a regular class (e.g., class DescriptorSessionManager) so state flows
(_state, _pendingProposals) and methods (createCallbacks, clearSessionState,
clearAll, removePendingProposal) become instance members, then register/provide
that class via your DI container or construct per-test/per-account; update all
call sites and tests to obtain an instance (instead of referencing the object)
and ensure createCallbacks returns callbacks bound to the instance so session
events modify the instance state rather than global state.
- Around line 483-501: The confirm button currently parses threshold and
timelockMonths twice (once to compute valid and again inside the onClick), so
parse them once into local values (e.g., parsedThreshold and parsedTimelock)
outside the TextButton and use those for both the enabled check and the onClick;
update the confirmButton lambda (the TextButton in WalletDescriptorScreen) to
use the pre-parsed parsedThreshold/parsedTimelock and call onPropose(network,
listOf(RecoveryTierConfig(parsedThreshold, parsedTimelock))) directly, removing
the redundant re-parsing and duplicate range checks while keeping the same
validity constraints (1u..15u and 1u..120u).
- Around line 62-89: The callbacks update two separate StateFlows
(_pendingProposals and _state) non-atomically in onContributionNeeded,
onComplete and onFailed; protect these paired writes by serializing them with a
small mutex (e.g., declare a private val descriptorCallbacksMutex = Mutex()) and
wrap the paired updates inside descriptorCallbacksMutex.withLock { ... } so
onContributionNeeded does descriptorCallbacksMutex.withLock {
_pendingProposals.update { it + proposal }; _state.value =
DescriptorSessionState.ContributionNeeded(proposal) } and similarly wrap
removePendingProposal + _state.value assignments in onComplete and onFailed (and
update the removePendingProposal implementation to use the same mutex if it
mutates _pendingProposals) to ensure observers never see a partial state.

In
`@app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt`:
- Around line 12-17: Tests rely on the global DescriptorSessionManager singleton
which can cause cross-test or cross-class flakiness under parallel execution;
update the test to explicitly document this constraint and make the expectation
clear (add a comment above setup() referencing
DescriptorSessionManager.clearAll()), and as a longer-term fix refactor the
singleton into an injectable instance (replace the DescriptorSessionManager
object with a class/instance or introduce a provider interface and use
dependency injection in the code under test) so tests can create and inject
isolated instances instead of relying on the global clearAll().

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between e600d7b and 7683c47.

📒 Files selected for processing (3)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

67-69: Deduplicate pending proposals by sessionId before appending.

Line 68 always appends, so callback retries/replays can create duplicate pending rows for the same session. Guarding by sessionId keeps UI/actions idempotent.

Proposed refinement
 override fun onContributionNeeded(proposal: DescriptorProposal) {
-    _pendingProposals.update { it + proposal }
+    _pendingProposals.update { current ->
+        if (current.any { it.sessionId == proposal.sessionId }) current else current + proposal
+    }
     _state.value = DescriptorSessionState.ContributionNeeded(proposal)
 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 67 - 69, onContributionNeeded currently always appends the incoming
DescriptorProposal to _pendingProposals, causing duplicates if the callback is
retried; update the override fun onContributionNeeded(proposal:
DescriptorProposal) to check existing _pendingProposals for a matching sessionId
on DescriptorProposal and only append if no entry with the same sessionId
exists, then continue to set _state.value =
DescriptorSessionState.ContributionNeeded(proposal); reference the
_pendingProposals.update call and the DescriptorProposal.sessionId property when
implementing the guard to keep the action idempotent.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 122-128: The onFailure handlers attached to your runCatching
blocks (e.g., the block wrapping withContext(Dispatchers.IO) {
keepMobile.walletDescriptorList() } that sets descriptors and shows Toasts)
currently swallow all throwables including CancellationException; update each
onFailure { ... } to rethrow if the caught exception is a CancellationException
(e.g., if (it is CancellationException) throw it) and only show the error Toast
or handle non-cancellation exceptions otherwise so coroutine cancellation
semantics are preserved.

---

Nitpick comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 67-69: onContributionNeeded currently always appends the incoming
DescriptorProposal to _pendingProposals, causing duplicates if the callback is
retried; update the override fun onContributionNeeded(proposal:
DescriptorProposal) to check existing _pendingProposals for a matching sessionId
on DescriptorProposal and only append if no entry with the same sessionId
exists, then continue to set _state.value =
DescriptorSessionState.ContributionNeeded(proposal); reference the
_pendingProposals.update call and the DescriptorProposal.sessionId property when
implementing the guard to keep the action idempotent.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 7683c47 and 03df530.

📒 Files selected for processing (1)
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt

@wksantiago wksantiago linked an issue Feb 24, 2026 that may be closed by this pull request
Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

♻️ Duplicate comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

262-275: ⚠️ Potential issue | 🟡 Minor

Keep the export dialog open when export fails.

showExportDialog = null at Line 274 runs even after failure, so a transient export error forces users to reopen and reselect format.

Suggested fix
             onExport = { format ->
                 scope.launch {
                     runCatching {
-                        val exported = withContext(Dispatchers.IO) {
+                        withContext(Dispatchers.IO) {
                             keepMobile.walletDescriptorExport(descriptor.groupPubkey, format)
                         }
-                        copySensitiveText(context, exported)
-                        Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
+                    }.onSuccess { exported ->
+                        copySensitiveText(context, exported)
+                        Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
+                        showExportDialog = null
                     }.onFailure { e ->
                         if (e is CancellationException) throw e
                         Log.w(TAG, "Failed to export descriptor", e)
                         Toast.makeText(context, "Export failed", Toast.LENGTH_LONG).show()
                     }
-                    showExportDialog = null
                 }
             },
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 262 - 275, The export dialog is being closed unconditionally
because showExportDialog = null runs after runCatching even on failure; change
the flow so showExportDialog is cleared only on successful export—e.g., use
runCatching(...).onSuccess { showExportDialog = null } (or move the assignment
into the success block after copySensitiveText) while keeping the current
CancellationException rethrow behavior and existing error handling for
keepMobile.walletDescriptorExport and copySensitiveText.
🧹 Nitpick comments (1)
app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt (1)

101-110: Add a regression test for duplicate sessionId proposals.

DescriptorSessionManager deduplicates pending proposals by sessionId in app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (Lines 73-75), but this suite doesn’t currently lock that behavior.

Suggested test case
+    `@Test`
+    fun `duplicate sessionId does not duplicate pending proposals`() = runTest {
+        val callbacks = DescriptorSessionManager.createCallbacks()
+        val p1 = makeProposal(sessionId = "dup", network = "bitcoin")
+        val p2 = makeProposal(sessionId = "dup", network = "signet")
+
+        callbacks.onContributionNeeded(p1)
+        callbacks.onContributionNeeded(p2)
+
+        val pending = DescriptorSessionManager.pendingProposals.first()
+        assertEquals(1, pending.size)
+        assertEquals("dup", pending[0].sessionId)
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt`
around lines 101 - 110, The test currently checks accumulation of proposals but
doesn’t assert the deduplication-by-sessionId behavior; add a new or update the
existing test in DescriptorSessionManagerTest to submit two proposals with the
same sessionId (using DescriptorSessionManager.createCallbacks() and
callbacks.onContributionNeeded(...)) and one with a different sessionId, then
read DescriptorSessionManager.pendingProposals.first() and assert that proposals
are deduplicated by sessionId (i.e., size reflects unique sessionIds and the
duplicate sessionId appears only once); reference the methods createCallbacks,
onContributionNeeded and the pendingProposals flow to locate and modify the
test.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt`:
- Around line 399-404: The current runCatching around
keepMobile.walletDescriptorSetCallbacks swallows all non-cancellation
exceptions; update the onFailure handler so that CancellationException is
rethrown but any other Throwable is surfaced (e.g., logged and/or reported)
instead of ignored. Specifically, modify the runCatching { ... }.onFailure block
that surrounds
keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
so non-cancellation errors are logged with a clear message (include the
exception) and/or reported to your error-tracking system, ensuring failures to
register descriptor callbacks are visible while preserving cancellation
behavior; keep the surrounding lifecycleOwner.lifecycle.repeatOnLifecycle usage
intact.

---

Duplicate comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 262-275: The export dialog is being closed unconditionally because
showExportDialog = null runs after runCatching even on failure; change the flow
so showExportDialog is cleared only on successful export—e.g., use
runCatching(...).onSuccess { showExportDialog = null } (or move the assignment
into the success block after copySensitiveText) while keeping the current
CancellationException rethrow behavior and existing error handling for
keepMobile.walletDescriptorExport and copySensitiveText.

---

Nitpick comments:
In
`@app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt`:
- Around line 101-110: The test currently checks accumulation of proposals but
doesn’t assert the deduplication-by-sessionId behavior; add a new or update the
existing test in DescriptorSessionManagerTest to submit two proposals with the
same sessionId (using DescriptorSessionManager.createCallbacks() and
callbacks.onContributionNeeded(...)) and one with a different sessionId, then
read DescriptorSessionManager.pendingProposals.first() and assert that proposals
are deduplicated by sessionId (i.e., size reflects unique sessionIds and the
duplicate sessionId appears only once); reference the methods createCallbacks,
onContributionNeeded and the pendingProposals flow to locate and modify the
test.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 03df530 and 3baf1e0.

📒 Files selected for processing (3)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (2)
app/src/main/kotlin/io/privkey/keep/MainActivity.kt (2)

399-407: ⚠️ Potential issue | 🟡 Minor

Callback registration failure still lacks user-visible notification.

Log.e was added (addressing logging from the prior review), but the user-facing Toast was not. If callback registration fails silently, descriptor session updates stop working without any indication to the user.

🛡️ Proposed fix to surface the failure
     }.onFailure {
         if (it is CancellationException) throw it
         Log.e("MainActivity", "Failed to set descriptor callbacks", it)
+        Toast.makeText(appContext, "Descriptor updates unavailable", Toast.LENGTH_SHORT).show()
     }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt` around lines 399 - 407,
The runCatching block that calls
keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
logs failures but doesn't notify the user; update the onFailure handler to
rethrow CancellationException as it does, and for other exceptions show a
user-visible Toast (or Snackbar) with a short error message (e.g., "Failed to
set descriptor callbacks") in addition to calling Log.e, so the user is informed
when descriptor callback registration fails; ensure you use the Activity context
(this or lifecycleOwner) when creating the Toast and keep the existing Log.e
invocation.

416-420: ⚠️ Potential issue | 🟡 Minor

Silent swallow of walletDescriptorList() failures in the polling hot path.

Non-CancellationException failures are silently collapsed to 0 on every 10-second poll cycle with no log output, making it impossible to distinguish "no descriptors" from "call failed".

🐛 Proposed fix to log failures
     val dc = if (h) {
         runCatching { keepMobile.walletDescriptorList().size }
-            .onFailure { if (it is CancellationException) throw it }
+            .onFailure { e ->
+                if (e is CancellationException) throw e
+                Log.w("MainActivity", "Failed to fetch descriptor count", e)
+            }
             .getOrDefault(0)
     } else 0
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt` around lines 416 - 420,
The polling hot path currently swallows all non-CancellationException errors
from keepMobile.walletDescriptorList() by using runCatching().getOrDefault(0);
change that runCatching block to rethrow CancellationException but log other
exceptions before returning 0 so failures aren’t indistinguishable from "no
descriptors". Specifically, update the block around
keepMobile.walletDescriptorList() (the dc assignment) to: use runCatching {
keepMobile.walletDescriptorList().size } .onFailure { if (it is
CancellationException) throw it else Log.e("MainActivity", "walletDescriptorList
failed", it) } .getOrDefault(0) (or use the existing app logger if one exists)
so errors are recorded while preserving the 0 fallback.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt`:
- Around line 399-407: The runCatching block that calls
keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks())
logs failures but doesn't notify the user; update the onFailure handler to
rethrow CancellationException as it does, and for other exceptions show a
user-visible Toast (or Snackbar) with a short error message (e.g., "Failed to
set descriptor callbacks") in addition to calling Log.e, so the user is informed
when descriptor callback registration fails; ensure you use the Activity context
(this or lifecycleOwner) when creating the Toast and keep the existing Log.e
invocation.
- Around line 416-420: The polling hot path currently swallows all
non-CancellationException errors from keepMobile.walletDescriptorList() by using
runCatching().getOrDefault(0); change that runCatching block to rethrow
CancellationException but log other exceptions before returning 0 so failures
aren’t indistinguishable from "no descriptors". Specifically, update the block
around keepMobile.walletDescriptorList() (the dc assignment) to: use runCatching
{ keepMobile.walletDescriptorList().size } .onFailure { if (it is
CancellationException) throw it else Log.e("MainActivity", "walletDescriptorList
failed", it) } .getOrDefault(0) (or use the existing app logger if one exists)
so errors are recorded while preserving the 0 fallback.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3baf1e0 and 2c42d1a.

📒 Files selected for processing (1)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt

Copy link

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

♻️ Duplicate comments (1)
app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt (1)

291-304: ⚠️ Potential issue | 🟡 Minor

Keep the export dialog open when export fails.

The dialog is closed regardless of outcome, so transient failures force the user to re-open and repeat actions.

Suggested fix
                 scope.launch {
                     runCatching {
                         val exported = withContext(Dispatchers.IO) {
                             keepMobile.walletDescriptorExport(descriptor.groupPubkey, format)
                         }
                         copySensitiveText(context, exported)
                         Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show()
+                    }.onSuccess {
+                        showExportDialog = null
                     }.onFailure { e ->
                         if (e is CancellationException) throw e
                         Log.w(TAG, "Failed to export descriptor", e)
                         Toast.makeText(context, "Export failed", Toast.LENGTH_LONG).show()
                     }
                     isExporting = false
-                    showExportDialog = null
                 }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`
around lines 291 - 304, The export dialog is always closed because isExporting
is set to false and showExportDialog is cleared after the runCatching block;
change this so that showExportDialog is only cleared when the export succeeds.
Specifically, move the showExportDialog = null (and any UI success state
transitions) into the success path of the runCatching (e.g., after
copySensitiveText/Toast on success) and ensure isExporting is still reset to
false in both success and failure paths; keep the existing CancellationException
rethrow and leave showExportDialog untouched inside the onFailure block so the
dialog remains open on error.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 209-219: The removePendingProposal call and inFlightSessions
update are executed regardless of whether the backend action succeeded; change
the flow so DescriptorSessionManager.removePendingProposal(proposal.sessionId)
and inFlightSessions = inFlightSessions - proposal.sessionId are only executed
after a successful call to block(proposal.sessionId). In the scope.launch block
around runCatching { withContext(Dispatchers.IO) { block(proposal.sessionId) }
}, move the removal/update into the success path (e.g., in .onSuccess or after
checking result.isSuccess) while keeping the existing .onFailure behavior that
rethrows CancellationException and logs/shows the toast on errors.

In `@app/src/main/kotlin/io/privkey/keep/MainActivity.kt`:
- Around line 419-424: The current logic forces dc to 0 on any transient failure
when fetching keepMobile.walletDescriptorList(), which can show a spurious empty
state; change it to preserve the previous descriptor count until a successful
fetch by making the fetch update conditional: run the runCatching block and only
replace the existing dc value when it succeeds (rethrow CancellationException),
otherwise leave the prior dc unchanged; apply the same pattern for the other
occurrences (the similar blocks around lines 432 and 1213) and ensure the value
passed into PollResult(...) is the preserved previous count unless the fetch
succeeds.

---

Duplicate comments:
In `@app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt`:
- Around line 291-304: The export dialog is always closed because isExporting is
set to false and showExportDialog is cleared after the runCatching block; change
this so that showExportDialog is only cleared when the export succeeds.
Specifically, move the showExportDialog = null (and any UI success state
transitions) into the success path of the runCatching (e.g., after
copySensitiveText/Toast on success) and ensure isExporting is still reset to
false in both success and failure paths; keep the existing CancellationException
rethrow and leave showExportDialog untouched inside the onFailure block so the
dialog remains open on error.

ℹ️ Review info

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 3baf1e0 and 935ff0a.

📒 Files selected for processing (3)
  • app/src/main/kotlin/io/privkey/keep/MainActivity.kt
  • app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt

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.

Add wallet descriptor coordination UI

1 participant