From 9efb9c4e74da0fd3473324e2f1774a6d7b6df564 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 11:27:02 -0500 Subject: [PATCH 01/22] Add wallet descriptor management with security fixes --- .../kotlin/io/privkey/keep/ConnectionCards.kt | 26 + .../kotlin/io/privkey/keep/KeepMobileApp.kt | 2 + .../kotlin/io/privkey/keep/MainActivity.kt | 41 +- .../keep/descriptor/WalletDescriptorScreen.kt | 537 ++++++++++++++++++ 4 files changed, 601 insertions(+), 5 deletions(-) create mode 100644 app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt diff --git a/app/src/main/kotlin/io/privkey/keep/ConnectionCards.kt b/app/src/main/kotlin/io/privkey/keep/ConnectionCards.kt index 1d2cc96b..f191bc63 100644 --- a/app/src/main/kotlin/io/privkey/keep/ConnectionCards.kt +++ b/app/src/main/kotlin/io/privkey/keep/ConnectionCards.kt @@ -347,6 +347,32 @@ fun BunkerCard(status: BunkerStatus, onClick: () -> Unit) { } } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun WalletDescriptorCard(descriptorCount: Int, onClick: () -> Unit) { + Card(modifier = Modifier.fillMaxWidth(), onClick = onClick) { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text("Wallet Descriptors", style = MaterialTheme.typography.titleMedium) + Text( + "Manage multisig wallet descriptors", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Text( + if (descriptorCount > 0) "$descriptorCount" else "Manage", + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.primary + ) + } + } +} + @Composable fun Nip55SettingsCard( onSignPolicyClick: () -> Unit, diff --git a/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt b/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt index 407161dd..62c7e4bc 100644 --- a/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt +++ b/app/src/main/kotlin/io/privkey/keep/KeepMobileApp.kt @@ -2,6 +2,7 @@ package io.privkey.keep import android.app.Application import android.util.Log +import io.privkey.keep.descriptor.DescriptorSessionManager import io.privkey.keep.nip46.BunkerService import io.privkey.keep.nip55.AutoSigningSafeguards import io.privkey.keep.nip55.CallerVerificationStore @@ -321,6 +322,7 @@ class KeepMobileApp : Application() { _connectionState.value = ConnectionState() BunkerService.stop(this) bunkerConfigStore?.setEnabled(false) + DescriptorSessionManager.clearAll() withContext(Dispatchers.IO) { runAccountSwitchCleanup("revoke permissions") { permissionStore?.revokeAllPermissions() } runAccountSwitchCleanup("clear app settings") { permissionStore?.clearAllAppSettings() } diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index 85eb2137..acaede18 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -32,6 +32,8 @@ import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController +import io.privkey.keep.descriptor.DescriptorSessionManager +import io.privkey.keep.descriptor.WalletDescriptorScreen import io.privkey.keep.navigation.Route import io.privkey.keep.nip46.BunkerScreen import io.privkey.keep.nip46.BunkerService @@ -315,6 +317,8 @@ fun MainScreen( var biometricTimeout by remember { mutableStateOf(biometricTimeoutStore.getTimeout()) } var biometricLockOnLaunch by remember { mutableStateOf(biometricTimeoutStore.isLockOnLaunchEnabled()) } var showBunkerScreen by remember { mutableStateOf(false) } + var showWalletDescriptorScreen by remember { mutableStateOf(false) } + var descriptorCount by remember { mutableIntStateOf(0) } val bunkerUrl by BunkerService.bunkerUrl.collectAsState() val bunkerStatus by BunkerService.status.collectAsState() var proxyEnabled by remember { mutableStateOf(proxyConfigStore.isEnabled()) } @@ -376,6 +380,12 @@ fun MainScreen( withContext(Dispatchers.IO) { profileRelayConfigStore?.setRelaysForAccount(key, updated) } } + LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks()) + } + } + LaunchedEffect(Unit) { val initial = withContext(Dispatchers.IO) { val a = storage.listAllShares().map { it.toAccountInfo() } @@ -393,14 +403,15 @@ fun MainScreen( LaunchedEffect(Unit) { lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { repeat(Int.MAX_VALUE) { - val (newHasShare, newShareInfo, newAccounts, newActiveKey, newPeers, newPendingCount) = withContext(Dispatchers.IO) { + val (newHasShare, newShareInfo, newAccounts, newActiveKey, newPeers, newPendingCount, newDescriptorCount) = withContext(Dispatchers.IO) { val h = keepMobile.hasShare() val s = keepMobile.getShareInfo() val a = storage.listAllShares().map { it.toAccountInfo() } val k = storage.getActiveShareKey() val p = if (h) keepMobile.getPeers() else emptyList() val pc = if (h) keepMobile.getPendingRequests().size else 0 - PollResult(h, s, a, k, p, pc) + val dc = keepMobile.walletDescriptorList().size + PollResult(h, s, a, k, p, pc, dc) } hasShare = newHasShare shareInfo = newShareInfo @@ -408,6 +419,7 @@ fun MainScreen( activeAccountKey = newActiveKey peers = newPeers pendingCount = newPendingCount + descriptorCount = newDescriptorCount refreshCertificatePins() profileRelays = withContext(Dispatchers.IO) { loadProfileRelays(newActiveKey) } delay(10_000) @@ -552,6 +564,14 @@ fun MainScreen( return } + if (showWalletDescriptorScreen) { + WalletDescriptorScreen( + keepMobile = keepMobile, + onDismiss = { showWalletDescriptorScreen = false } + ) + return + } + if (showAccountSwitcher) { AccountSwitcherSheet( accounts = allAccounts, @@ -696,11 +716,13 @@ fun MainScreen( AppsTab( hasShare = hasShare, bunkerStatus = bunkerStatus, + descriptorCount = descriptorCount, onConnectedAppsClick = { showConnectedApps = true }, onSignPolicyClick = { showSignPolicyScreen = true }, onPermissionsClick = { showPermissionsScreen = true }, onHistoryClick = { showHistoryScreen = true }, - onBunkerClick = { showBunkerScreen = true } + onBunkerClick = { showBunkerScreen = true }, + onWalletDescriptorClick = { showWalletDescriptorScreen = true } ) } @@ -906,11 +928,13 @@ private fun HomeTab( private fun AppsTab( hasShare: Boolean, bunkerStatus: BunkerStatus, + descriptorCount: Int, onConnectedAppsClick: () -> Unit, onSignPolicyClick: () -> Unit, onPermissionsClick: () -> Unit, onHistoryClick: () -> Unit, - onBunkerClick: () -> Unit + onBunkerClick: () -> Unit, + onWalletDescriptorClick: () -> Unit ) { Column( modifier = Modifier @@ -938,6 +962,12 @@ private fun AppsTab( status = bunkerStatus, onClick = onBunkerClick ) + Spacer(modifier = Modifier.height(16.dp)) + + WalletDescriptorCard( + descriptorCount = descriptorCount, + onClick = onWalletDescriptorClick + ) } else { Card(modifier = Modifier.fillMaxWidth()) { Column( @@ -1170,7 +1200,8 @@ private data class PollResult( val allAccounts: List, val activeAccountKey: String?, val peers: List, - val pendingCount: Int + val pendingCount: Int, + val descriptorCount: Int ) @Composable diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt new file mode 100644 index 00000000..2f6084b3 --- /dev/null +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -0,0 +1,537 @@ +package io.privkey.keep.descriptor + +import android.widget.Toast +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.dp +import io.privkey.keep.copySensitiveText +import io.privkey.keep.setSecureScreen +import io.privkey.keep.uniffi.DescriptorCallbacks +import io.privkey.keep.uniffi.DescriptorProposal +import io.privkey.keep.uniffi.KeepMobile +import io.privkey.keep.uniffi.RecoveryTierConfig +import io.privkey.keep.uniffi.WalletDescriptorInfo +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +private object ExportFormat { + const val SPARROW = "sparrow" + const val RAW = "raw" +} + +sealed class DescriptorSessionState { + data object Idle : DescriptorSessionState() + data class Proposed(val sessionId: String) : DescriptorSessionState() + data class ContributionNeeded(val proposal: DescriptorProposal) : DescriptorSessionState() + data class Contributed(val sessionId: String, val shareIndex: UShort) : DescriptorSessionState() + data class Complete( + val sessionId: String, + val externalDescriptor: String, + val internalDescriptor: String + ) : DescriptorSessionState() + data class Failed(val sessionId: String, val error: String) : DescriptorSessionState() +} + +object DescriptorSessionManager { + private val _state = MutableStateFlow(DescriptorSessionState.Idle) + val state: StateFlow = _state.asStateFlow() + + private val _pendingProposals = MutableStateFlow>(emptyList()) + val pendingProposals: StateFlow> = _pendingProposals.asStateFlow() + + fun createCallbacks(): DescriptorCallbacks = object : DescriptorCallbacks { + override fun onProposed(sessionId: String) { + _state.value = DescriptorSessionState.Proposed(sessionId) + } + + override fun onContributionNeeded(proposal: DescriptorProposal) { + _pendingProposals.update { it + proposal } + _state.value = DescriptorSessionState.ContributionNeeded(proposal) + } + + override fun onContributed(sessionId: String, shareIndex: UShort) { + _state.value = DescriptorSessionState.Contributed(sessionId, shareIndex) + } + + override fun onComplete( + sessionId: String, + externalDescriptor: String, + internalDescriptor: String + ) { + _pendingProposals.update { it.filter { p -> p.sessionId != sessionId } } + _state.value = DescriptorSessionState.Complete(sessionId, externalDescriptor, internalDescriptor) + } + + override fun onFailed(sessionId: String, error: String) { + _pendingProposals.update { it.filter { p -> p.sessionId != sessionId } } + _state.value = DescriptorSessionState.Failed(sessionId, error) + } + } + + fun clearSessionState() { + _state.value = DescriptorSessionState.Idle + } + + fun clearAll() { + _state.value = DescriptorSessionState.Idle + _pendingProposals.value = emptyList() + } + + fun removePendingProposal(sessionId: String) { + _pendingProposals.update { it.filter { p -> p.sessionId != sessionId } } + } +} + +@Composable +fun WalletDescriptorScreen( + keepMobile: KeepMobile, + onDismiss: () -> Unit +) { + val context = LocalContext.current + val scope = rememberCoroutineScope() + var descriptors by remember { mutableStateOf>(emptyList()) } + var showProposeDialog by remember { mutableStateOf(false) } + var showExportDialog by remember { mutableStateOf(null) } + var showDeleteConfirm by remember { mutableStateOf(null) } + val sessionState by DescriptorSessionManager.state.collectAsState() + val pendingProposals by DescriptorSessionManager.pendingProposals.collectAsState() + + fun refreshDescriptors() { + scope.launch { + descriptors = withContext(Dispatchers.IO) { keepMobile.walletDescriptorList() } + } + } + + LaunchedEffect(Unit) { + refreshDescriptors() + } + + LaunchedEffect(sessionState) { + if (sessionState is DescriptorSessionState.Complete) { + refreshDescriptors() + } + } + + DisposableEffect(Unit) { + setSecureScreen(context, true) + onDispose { + setSecureScreen(context, false) + DescriptorSessionManager.clearSessionState() + } + } + + Column( + modifier = Modifier + .fillMaxSize() + .statusBarsPadding() + .padding(24.dp) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Wallet Descriptors", style = MaterialTheme.typography.headlineMedium) + Spacer(modifier = Modifier.height(16.dp)) + + SessionStatusCard(sessionState) + + if (pendingProposals.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) + PendingContributionsCard( + proposals = pendingProposals, + onApprove = { proposal -> + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + keepMobile.walletDescriptorApproveContribution(proposal.sessionId) + } + DescriptorSessionManager.removePendingProposal(proposal.sessionId) + }.onFailure { + Toast.makeText(context, "Failed to approve", Toast.LENGTH_SHORT).show() + } + } + }, + onReject = { proposal -> + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + keepMobile.walletDescriptorCancel(proposal.sessionId) + } + DescriptorSessionManager.removePendingProposal(proposal.sessionId) + }.onFailure { + Toast.makeText(context, "Failed to reject", Toast.LENGTH_SHORT).show() + } + } + } + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + Button( + onClick = { showProposeDialog = true }, + modifier = Modifier.fillMaxWidth() + ) { + Text("New Descriptor") + } + + Spacer(modifier = Modifier.height(16.dp)) + + DescriptorListCard( + descriptors = descriptors, + onExport = { showExportDialog = it }, + onDelete = { showDeleteConfirm = it } + ) + + Spacer(modifier = Modifier.height(24.dp)) + + TextButton(onClick = onDismiss, modifier = Modifier.fillMaxWidth()) { + Text("Back") + } + } + + if (showProposeDialog) { + ProposeDescriptorDialog( + onPropose = { network, tiers -> + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + keepMobile.walletDescriptorPropose(network, tiers) + } + }.onFailure { + Toast.makeText(context, "Failed to propose descriptor", Toast.LENGTH_SHORT).show() + } + showProposeDialog = false + } + }, + onDismiss = { showProposeDialog = false } + ) + } + + showExportDialog?.let { descriptor -> + ExportDescriptorDialog( + descriptor = descriptor, + onExport = { format -> + 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() + }.onFailure { + Toast.makeText(context, "Export failed", Toast.LENGTH_SHORT).show() + } + showExportDialog = null + } + }, + onDismiss = { showExportDialog = null } + ) + } + + showDeleteConfirm?.let { descriptor -> + DeleteDescriptorDialog( + descriptor = descriptor, + onConfirm = { + scope.launch { + runCatching { + withContext(Dispatchers.IO) { + keepMobile.walletDescriptorDelete(descriptor.groupPubkey) + } + refreshDescriptors() + }.onFailure { + Toast.makeText(context, "Delete failed", Toast.LENGTH_SHORT).show() + } + showDeleteConfirm = null + } + }, + onDismiss = { showDeleteConfirm = null } + ) + } +} + +@Composable +private fun SessionStatusCard(state: DescriptorSessionState) { + if (state is DescriptorSessionState.Idle) return + + val (statusText, statusColor) = when (state) { + is DescriptorSessionState.Proposed -> "Proposed — waiting for contributions" to MaterialTheme.colorScheme.primary + is DescriptorSessionState.ContributionNeeded -> "Contribution needed" to MaterialTheme.colorScheme.tertiary + is DescriptorSessionState.Contributed -> "Share ${state.shareIndex} contributed" to MaterialTheme.colorScheme.secondary + is DescriptorSessionState.Complete -> "Descriptor complete" to MaterialTheme.colorScheme.primary + is DescriptorSessionState.Failed -> "Failed: ${state.error}" to MaterialTheme.colorScheme.error + is DescriptorSessionState.Idle -> return + } + + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Session Status", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + Text(statusText, color = statusColor, style = MaterialTheme.typography.bodyMedium) + if (state is DescriptorSessionState.Complete) { + Spacer(modifier = Modifier.height(8.dp)) + Text( + "External: ${state.externalDescriptor.take(40)}...", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun PendingContributionsCard( + proposals: List, + onApprove: (DescriptorProposal) -> Unit, + onReject: (DescriptorProposal) -> Unit +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Pending Contributions", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + proposals.forEachIndexed { index, proposal -> + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + proposal.network, + style = MaterialTheme.typography.bodyMedium + ) + Text( + "${proposal.tiers.size} tier(s)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton(onClick = { onReject(proposal) }) { + Text("Reject") + } + Button(onClick = { onApprove(proposal) }) { + Text("Approve") + } + } + } + if (index < proposals.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(vertical = 4.dp)) + } + } + } + } +} + +@Composable +private fun DescriptorListCard( + descriptors: List, + onExport: (WalletDescriptorInfo) -> Unit, + onDelete: (WalletDescriptorInfo) -> Unit +) { + Card(modifier = Modifier.fillMaxWidth()) { + Column(modifier = Modifier.padding(16.dp)) { + Text("Descriptors (${descriptors.size})", style = MaterialTheme.typography.titleMedium) + Spacer(modifier = Modifier.height(8.dp)) + if (descriptors.isEmpty()) { + Text( + "No wallet descriptors", + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } else { + descriptors.forEachIndexed { index, descriptor -> + DescriptorRow(descriptor, onExport, onDelete) + if (index < descriptors.lastIndex) { + HorizontalDivider(modifier = Modifier.padding(vertical = 8.dp)) + } + } + } + } + } +} + +@Composable +private fun DescriptorRow( + descriptor: WalletDescriptorInfo, + onExport: (WalletDescriptorInfo) -> Unit, + onDelete: (WalletDescriptorInfo) -> Unit +) { + val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } + + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text( + "${descriptor.groupPubkey.take(8)}...${descriptor.groupPubkey.takeLast(6)}", + style = MaterialTheme.typography.bodyMedium + ) + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Text( + descriptor.network, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.primary + ) + Text( + dateFormat.format(Date(descriptor.createdAt.toLong() * 1000)), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Spacer(modifier = Modifier.height(8.dp)) + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + OutlinedButton( + onClick = { onExport(descriptor) }, + modifier = Modifier.weight(1f) + ) { + Text("Export") + } + OutlinedButton( + onClick = { onDelete(descriptor) }, + modifier = Modifier.weight(1f), + colors = ButtonDefaults.outlinedButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete") + } + } + } +} + +@Composable +private fun ProposeDescriptorDialog( + onPropose: (String, List) -> Unit, + onDismiss: () -> Unit +) { + var network by remember { mutableStateOf("bitcoin") } + var threshold by remember { mutableStateOf("2") } + var timelockMonths by remember { mutableStateOf("6") } + + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("New Wallet Descriptor") }, + text = { + Column { + Text("Network", style = MaterialTheme.typography.labelMedium) + Spacer(modifier = Modifier.height(4.dp)) + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + listOf("bitcoin", "testnet", "signet").forEach { net -> + FilterChip( + selected = network == net, + onClick = { network = net }, + label = { Text(net) } + ) + } + } + Spacer(modifier = Modifier.height(16.dp)) + Text("Recovery Tier", style = MaterialTheme.typography.labelMedium) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = threshold, + onValueChange = { threshold = it.filter { c -> c.isDigit() } }, + label = { Text("Threshold") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + Spacer(modifier = Modifier.height(8.dp)) + OutlinedTextField( + value = timelockMonths, + onValueChange = { timelockMonths = it.filter { c -> c.isDigit() } }, + label = { Text("Timelock (months)") }, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + TextButton( + onClick = { + val t = threshold.toUIntOrNull() ?: return@TextButton + val tl = timelockMonths.toUIntOrNull() ?: return@TextButton + if (t < 1u || tl < 1u) return@TextButton + onPropose(network, listOf(RecoveryTierConfig(t, tl))) + } + ) { + Text("Propose") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +@Composable +private fun ExportDescriptorDialog( + descriptor: WalletDescriptorInfo, + onExport: (String) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Export Descriptor") }, + text = { + Column { + Text( + "${descriptor.groupPubkey.take(8)}...${descriptor.groupPubkey.takeLast(6)}", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text("Choose export format:") + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { onExport(ExportFormat.SPARROW) }) { Text("Sparrow") } + TextButton(onClick = { onExport(ExportFormat.RAW) }) { Text("Raw") } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +@Composable +private fun DeleteDescriptorDialog( + descriptor: WalletDescriptorInfo, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete Descriptor?") }, + text = { + Text("This will permanently remove the wallet descriptor for ${descriptor.groupPubkey.take(8)}...${descriptor.groupPubkey.takeLast(6)}") + }, + confirmButton = { + TextButton( + onClick = { onConfirm() }, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text("Delete") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} From 712ac4e78ec0cc89745ef166f8572391c6fae4f1 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 11:33:46 -0500 Subject: [PATCH 02/22] Code cleanup and FlowRow fix for network chips --- .../keep/descriptor/WalletDescriptorScreen.kt | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 2f6084b3..5b408611 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -33,6 +33,9 @@ private object ExportFormat { const val RAW = "raw" } +private fun truncateGroupPubkey(key: String): String = + "${key.take(8)}...${key.takeLast(6)}" + sealed class DescriptorSessionState { data object Idle : DescriptorSessionState() data class Proposed(val sessionId: String) : DescriptorSessionState() @@ -72,12 +75,12 @@ object DescriptorSessionManager { externalDescriptor: String, internalDescriptor: String ) { - _pendingProposals.update { it.filter { p -> p.sessionId != sessionId } } + removePendingProposal(sessionId) _state.value = DescriptorSessionState.Complete(sessionId, externalDescriptor, internalDescriptor) } override fun onFailed(sessionId: String, error: String) { - _pendingProposals.update { it.filter { p -> p.sessionId != sessionId } } + removePendingProposal(sessionId) _state.value = DescriptorSessionState.Failed(sessionId, error) } } @@ -373,7 +376,7 @@ private fun DescriptorRow( Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text( - "${descriptor.groupPubkey.take(8)}...${descriptor.groupPubkey.takeLast(6)}", + truncateGroupPubkey(descriptor.groupPubkey), style = MaterialTheme.typography.bodyMedium ) Spacer(modifier = Modifier.height(4.dp)) @@ -429,7 +432,7 @@ private fun ProposeDescriptorDialog( Column { Text("Network", style = MaterialTheme.typography.labelMedium) Spacer(modifier = Modifier.height(4.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + FlowRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { listOf("bitcoin", "testnet", "signet").forEach { net -> FilterChip( selected = network == net, @@ -488,7 +491,7 @@ private fun ExportDescriptorDialog( text = { Column { Text( - "${descriptor.groupPubkey.take(8)}...${descriptor.groupPubkey.takeLast(6)}", + truncateGroupPubkey(descriptor.groupPubkey), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -518,11 +521,11 @@ private fun DeleteDescriptorDialog( onDismissRequest = onDismiss, title = { Text("Delete Descriptor?") }, text = { - Text("This will permanently remove the wallet descriptor for ${descriptor.groupPubkey.take(8)}...${descriptor.groupPubkey.takeLast(6)}") + Text("This will permanently remove the wallet descriptor for ${truncateGroupPubkey(descriptor.groupPubkey)}") }, confirmButton = { TextButton( - onClick = { onConfirm() }, + onClick = onConfirm, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error ) From e600d7bcaeff9f2ae12a2a2c34e4d47c32996b51 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 11:47:46 -0500 Subject: [PATCH 03/22] Add error handling to refreshDescriptors --- .../io/privkey/keep/descriptor/WalletDescriptorScreen.kt | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 5b408611..22ce18cb 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -115,7 +115,13 @@ fun WalletDescriptorScreen( fun refreshDescriptors() { scope.launch { - descriptors = withContext(Dispatchers.IO) { keepMobile.walletDescriptorList() } + runCatching { + withContext(Dispatchers.IO) { keepMobile.walletDescriptorList() } + }.onSuccess { + descriptors = it + }.onFailure { + Toast.makeText(context, "Failed to load descriptors", Toast.LENGTH_SHORT).show() + } } } From 550803e9c1971ff014f9327969301094161236d9 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 12:00:32 -0500 Subject: [PATCH 04/22] Surface actual error messages in wallet descriptor toasts --- .../keep/descriptor/WalletDescriptorScreen.kt | 20 +++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 22ce18cb..86239477 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -167,8 +167,8 @@ fun WalletDescriptorScreen( keepMobile.walletDescriptorApproveContribution(proposal.sessionId) } DescriptorSessionManager.removePendingProposal(proposal.sessionId) - }.onFailure { - Toast.makeText(context, "Failed to approve", Toast.LENGTH_SHORT).show() + }.onFailure { e -> + Toast.makeText(context, e.message ?: "Failed to approve", Toast.LENGTH_LONG).show() } } }, @@ -179,8 +179,8 @@ fun WalletDescriptorScreen( keepMobile.walletDescriptorCancel(proposal.sessionId) } DescriptorSessionManager.removePendingProposal(proposal.sessionId) - }.onFailure { - Toast.makeText(context, "Failed to reject", Toast.LENGTH_SHORT).show() + }.onFailure { e -> + Toast.makeText(context, e.message ?: "Failed to reject", Toast.LENGTH_LONG).show() } } } @@ -219,8 +219,8 @@ fun WalletDescriptorScreen( withContext(Dispatchers.IO) { keepMobile.walletDescriptorPropose(network, tiers) } - }.onFailure { - Toast.makeText(context, "Failed to propose descriptor", Toast.LENGTH_SHORT).show() + }.onFailure { e -> + Toast.makeText(context, e.message ?: "Failed to propose descriptor", Toast.LENGTH_LONG).show() } showProposeDialog = false } @@ -240,8 +240,8 @@ fun WalletDescriptorScreen( } copySensitiveText(context, exported) Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() - }.onFailure { - Toast.makeText(context, "Export failed", Toast.LENGTH_SHORT).show() + }.onFailure { e -> + Toast.makeText(context, e.message ?: "Export failed", Toast.LENGTH_LONG).show() } showExportDialog = null } @@ -260,8 +260,8 @@ fun WalletDescriptorScreen( keepMobile.walletDescriptorDelete(descriptor.groupPubkey) } refreshDescriptors() - }.onFailure { - Toast.makeText(context, "Delete failed", Toast.LENGTH_SHORT).show() + }.onFailure { e -> + Toast.makeText(context, e.message ?: "Delete failed", Toast.LENGTH_LONG).show() } showDeleteConfirm = null } From 4074643496ef9b6e34e454f7acbaea3da5068cd4 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 12:00:36 -0500 Subject: [PATCH 05/22] Add DescriptorSessionManager unit tests --- .../DescriptorSessionManagerTest.kt | 176 ++++++++++++++++++ 1 file changed, 176 insertions(+) create mode 100644 app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt diff --git a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt new file mode 100644 index 00000000..c581250c --- /dev/null +++ b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt @@ -0,0 +1,176 @@ +package io.privkey.keep.descriptor + +import io.privkey.keep.uniffi.DescriptorProposal +import io.privkey.keep.uniffi.RecoveryTierConfig +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class DescriptorSessionManagerTest { + + @Before + fun setup() { + DescriptorSessionManager.clearAll() + } + + private fun makeProposal( + sessionId: String = "session-1", + network: String = "bitcoin" + ) = DescriptorProposal(sessionId, network, listOf(RecoveryTierConfig(2u, 6u))) + + @Test + fun `initial state is Idle`() = runTest { + assertEquals(DescriptorSessionState.Idle, DescriptorSessionManager.state.first()) + } + + @Test + fun `onProposed transitions to Proposed`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + callbacks.onProposed("session-1") + assertEquals(DescriptorSessionState.Proposed("session-1"), DescriptorSessionManager.state.first()) + } + + @Test + fun `onContributionNeeded transitions state and adds proposal`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + val proposal = makeProposal() + callbacks.onContributionNeeded(proposal) + + assertEquals(DescriptorSessionState.ContributionNeeded(proposal), DescriptorSessionManager.state.first()) + assertEquals(listOf(proposal), DescriptorSessionManager.pendingProposals.first()) + } + + @Test + fun `onContributed transitions to Contributed`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + callbacks.onContributed("session-1", 0u) + val state = DescriptorSessionManager.state.first() + assertTrue(state is DescriptorSessionState.Contributed) + assertEquals("session-1", (state as DescriptorSessionState.Contributed).sessionId) + assertEquals(0.toUShort(), state.shareIndex) + } + + @Test + fun `onComplete transitions to Complete and removes proposal`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + val proposal = makeProposal() + callbacks.onContributionNeeded(proposal) + assertEquals(1, DescriptorSessionManager.pendingProposals.first().size) + + callbacks.onComplete("session-1", "wpkh([ext])", "wpkh([int])") + + val state = DescriptorSessionManager.state.first() as DescriptorSessionState.Complete + assertEquals("session-1", state.sessionId) + assertEquals("wpkh([ext])", state.externalDescriptor) + assertEquals("wpkh([int])", state.internalDescriptor) + assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) + } + + @Test + fun `onFailed transitions to Failed and removes proposal`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + val proposal = makeProposal() + callbacks.onContributionNeeded(proposal) + + callbacks.onFailed("session-1", "timeout") + + val state = DescriptorSessionManager.state.first() as DescriptorSessionState.Failed + assertEquals("session-1", state.sessionId) + assertEquals("timeout", state.error) + assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) + } + + @Test + fun `propose on each network tracks correct network`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + listOf("bitcoin", "testnet", "signet").forEach { network -> + DescriptorSessionManager.clearAll() + val proposal = makeProposal(sessionId = "session-$network", network = network) + callbacks.onContributionNeeded(proposal) + + val pending = DescriptorSessionManager.pendingProposals.first() + assertEquals(1, pending.size) + assertEquals(network, pending[0].network) + } + } + + @Test + fun `multiple pending proposals accumulate`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + callbacks.onContributionNeeded(makeProposal("s1", "bitcoin")) + callbacks.onContributionNeeded(makeProposal("s2", "testnet")) + callbacks.onContributionNeeded(makeProposal("s3", "signet")) + + val pending = DescriptorSessionManager.pendingProposals.first() + assertEquals(3, pending.size) + assertEquals(listOf("bitcoin", "testnet", "signet"), pending.map { it.network }) + } + + @Test + fun `removePendingProposal only removes matching session`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + callbacks.onContributionNeeded(makeProposal("s1", "bitcoin")) + callbacks.onContributionNeeded(makeProposal("s2", "testnet")) + + DescriptorSessionManager.removePendingProposal("s1") + + val pending = DescriptorSessionManager.pendingProposals.first() + assertEquals(1, pending.size) + assertEquals("s2", pending[0].sessionId) + } + + @Test + fun `clearSessionState resets state but keeps proposals`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + callbacks.onContributionNeeded(makeProposal()) + + DescriptorSessionManager.clearSessionState() + + assertEquals(DescriptorSessionState.Idle, DescriptorSessionManager.state.first()) + assertEquals(1, DescriptorSessionManager.pendingProposals.first().size) + } + + @Test + fun `clearAll resets state and proposals`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + callbacks.onContributionNeeded(makeProposal()) + + DescriptorSessionManager.clearAll() + + assertEquals(DescriptorSessionState.Idle, DescriptorSessionManager.state.first()) + assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) + } + + @Test + fun `approve then complete full flow`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + callbacks.onProposed("s1") + assertTrue(DescriptorSessionManager.state.first() is DescriptorSessionState.Proposed) + + callbacks.onContributionNeeded(makeProposal("s1")) + assertTrue(DescriptorSessionManager.state.first() is DescriptorSessionState.ContributionNeeded) + + callbacks.onContributed("s1", 1u) + assertTrue(DescriptorSessionManager.state.first() is DescriptorSessionState.Contributed) + + callbacks.onComplete("s1", "ext-desc", "int-desc") + val final_ = DescriptorSessionManager.state.first() as DescriptorSessionState.Complete + assertEquals("ext-desc", final_.externalDescriptor) + assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) + } + + @Test + fun `reject flow transitions through Failed`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + callbacks.onContributionNeeded(makeProposal("s1")) + callbacks.onFailed("s1", "rejected by user") + + val state = DescriptorSessionManager.state.first() + assertTrue(state is DescriptorSessionState.Failed) + assertEquals("rejected by user", (state as DescriptorSessionState.Failed).error) + assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) + } +} From 7683c47cac97dbe231057b547707b93698eddba7 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 18:20:31 -0500 Subject: [PATCH 06/22] Fix review findings: polling guard, double-tap prevention, input validation --- .../kotlin/io/privkey/keep/MainActivity.kt | 2 +- .../keep/descriptor/WalletDescriptorScreen.kt | 40 +++++++++++++------ 2 files changed, 29 insertions(+), 13 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index acaede18..d6be3bef 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -410,7 +410,7 @@ fun MainScreen( val k = storage.getActiveShareKey() val p = if (h) keepMobile.getPeers() else emptyList() val pc = if (h) keepMobile.getPendingRequests().size else 0 - val dc = keepMobile.walletDescriptorList().size + val dc = if (h) keepMobile.walletDescriptorList().size else 0 PollResult(h, s, a, k, p, pc, dc) } hasShare = newHasShare diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 86239477..adf3ff5c 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -33,8 +33,11 @@ private object ExportFormat { const val RAW = "raw" } +private fun truncateText(text: String, maxLength: Int): String = + if (text.length <= maxLength) text else "${text.take(maxLength)}..." + private fun truncateGroupPubkey(key: String): String = - "${key.take(8)}...${key.takeLast(6)}" + if (key.length <= 14) key else "${key.take(8)}...${key.takeLast(6)}" sealed class DescriptorSessionState { data object Idle : DescriptorSessionState() @@ -110,6 +113,7 @@ fun WalletDescriptorScreen( var showProposeDialog by remember { mutableStateOf(false) } var showExportDialog by remember { mutableStateOf(null) } var showDeleteConfirm by remember { mutableStateOf(null) } + var inFlightSessions by remember { mutableStateOf(emptySet()) } val sessionState by DescriptorSessionManager.state.collectAsState() val pendingProposals by DescriptorSessionManager.pendingProposals.collectAsState() @@ -160,7 +164,10 @@ fun WalletDescriptorScreen( Spacer(modifier = Modifier.height(16.dp)) PendingContributionsCard( proposals = pendingProposals, + inFlightSessions = inFlightSessions, onApprove = { proposal -> + if (proposal.sessionId in inFlightSessions) return@PendingContributionsCard + inFlightSessions = inFlightSessions + proposal.sessionId scope.launch { runCatching { withContext(Dispatchers.IO) { @@ -170,9 +177,12 @@ fun WalletDescriptorScreen( }.onFailure { e -> Toast.makeText(context, e.message ?: "Failed to approve", Toast.LENGTH_LONG).show() } + inFlightSessions = inFlightSessions - proposal.sessionId } }, onReject = { proposal -> + if (proposal.sessionId in inFlightSessions) return@PendingContributionsCard + inFlightSessions = inFlightSessions + proposal.sessionId scope.launch { runCatching { withContext(Dispatchers.IO) { @@ -182,6 +192,7 @@ fun WalletDescriptorScreen( }.onFailure { e -> Toast.makeText(context, e.message ?: "Failed to reject", Toast.LENGTH_LONG).show() } + inFlightSessions = inFlightSessions - proposal.sessionId } } ) @@ -280,7 +291,7 @@ private fun SessionStatusCard(state: DescriptorSessionState) { is DescriptorSessionState.ContributionNeeded -> "Contribution needed" to MaterialTheme.colorScheme.tertiary is DescriptorSessionState.Contributed -> "Share ${state.shareIndex} contributed" to MaterialTheme.colorScheme.secondary is DescriptorSessionState.Complete -> "Descriptor complete" to MaterialTheme.colorScheme.primary - is DescriptorSessionState.Failed -> "Failed: ${state.error}" to MaterialTheme.colorScheme.error + is DescriptorSessionState.Failed -> "Failed: ${truncateText(state.error, 80)}" to MaterialTheme.colorScheme.error is DescriptorSessionState.Idle -> return } @@ -292,7 +303,7 @@ private fun SessionStatusCard(state: DescriptorSessionState) { if (state is DescriptorSessionState.Complete) { Spacer(modifier = Modifier.height(8.dp)) Text( - "External: ${state.externalDescriptor.take(40)}...", + "External: ${truncateText(state.externalDescriptor, 40)}", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) @@ -304,6 +315,7 @@ private fun SessionStatusCard(state: DescriptorSessionState) { @Composable private fun PendingContributionsCard( proposals: List, + inFlightSessions: Set, onApprove: (DescriptorProposal) -> Unit, onReject: (DescriptorProposal) -> Unit ) { @@ -329,10 +341,11 @@ private fun PendingContributionsCard( ) } Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - OutlinedButton(onClick = { onReject(proposal) }) { + val busy = proposal.sessionId in inFlightSessions + OutlinedButton(onClick = { onReject(proposal) }, enabled = !busy) { Text("Reject") } - Button(onClick = { onApprove(proposal) }) { + Button(onClick = { onApprove(proposal) }, enabled = !busy) { Text("Approve") } } @@ -453,7 +466,7 @@ private fun ProposeDescriptorDialog( OutlinedTextField( value = threshold, onValueChange = { threshold = it.filter { c -> c.isDigit() } }, - label = { Text("Threshold") }, + label = { Text("Threshold (1–15)") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) @@ -461,20 +474,23 @@ private fun ProposeDescriptorDialog( OutlinedTextField( value = timelockMonths, onValueChange = { timelockMonths = it.filter { c -> c.isDigit() } }, - label = { Text("Timelock (months)") }, + label = { Text("Timelock months (1–120)") }, singleLine = true, modifier = Modifier.fillMaxWidth() ) } }, confirmButton = { + val t = threshold.toUIntOrNull() + val tl = timelockMonths.toUIntOrNull() + val valid = t != null && tl != null && t in 1u..15u && tl in 1u..120u TextButton( onClick = { - val t = threshold.toUIntOrNull() ?: return@TextButton - val tl = timelockMonths.toUIntOrNull() ?: return@TextButton - if (t < 1u || tl < 1u) return@TextButton - onPropose(network, listOf(RecoveryTierConfig(t, tl))) - } + val tv = threshold.toUIntOrNull() ?: return@TextButton + val tlv = timelockMonths.toUIntOrNull() ?: return@TextButton + if (tv in 1u..15u && tlv in 1u..120u) onPropose(network, listOf(RecoveryTierConfig(tv, tlv))) + }, + enabled = valid ) { Text("Propose") } From 03df530070f10f1e2184b12a4629217bf44103ae Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 18:28:45 -0500 Subject: [PATCH 07/22] Keep dialogs open on failure for propose and delete --- .../io/privkey/keep/descriptor/WalletDescriptorScreen.kt | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index adf3ff5c..44561df6 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -230,10 +230,11 @@ fun WalletDescriptorScreen( withContext(Dispatchers.IO) { keepMobile.walletDescriptorPropose(network, tiers) } + }.onSuccess { + showProposeDialog = false }.onFailure { e -> Toast.makeText(context, e.message ?: "Failed to propose descriptor", Toast.LENGTH_LONG).show() } - showProposeDialog = false } }, onDismiss = { showProposeDialog = false } @@ -271,10 +272,11 @@ fun WalletDescriptorScreen( keepMobile.walletDescriptorDelete(descriptor.groupPubkey) } refreshDescriptors() + }.onSuccess { + showDeleteConfirm = null }.onFailure { e -> Toast.makeText(context, e.message ?: "Delete failed", Toast.LENGTH_LONG).show() } - showDeleteConfirm = null } }, onDismiss = { showDeleteConfirm = null } From 9eecf459a75b3f532a1a06966e3675fcd532a163 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 19:03:51 -0500 Subject: [PATCH 08/22] Re-throw CancellationException in descriptor runCatching blocks --- .../io/privkey/keep/descriptor/WalletDescriptorScreen.kt | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 44561df6..ab67e27b 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -17,6 +17,7 @@ import io.privkey.keep.uniffi.DescriptorProposal import io.privkey.keep.uniffi.KeepMobile import io.privkey.keep.uniffi.RecoveryTierConfig import io.privkey.keep.uniffi.WalletDescriptorInfo +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -124,6 +125,7 @@ fun WalletDescriptorScreen( }.onSuccess { descriptors = it }.onFailure { + if (it is CancellationException) throw it Toast.makeText(context, "Failed to load descriptors", Toast.LENGTH_SHORT).show() } } @@ -175,6 +177,7 @@ fun WalletDescriptorScreen( } DescriptorSessionManager.removePendingProposal(proposal.sessionId) }.onFailure { e -> + if (e is CancellationException) throw e Toast.makeText(context, e.message ?: "Failed to approve", Toast.LENGTH_LONG).show() } inFlightSessions = inFlightSessions - proposal.sessionId @@ -190,6 +193,7 @@ fun WalletDescriptorScreen( } DescriptorSessionManager.removePendingProposal(proposal.sessionId) }.onFailure { e -> + if (e is CancellationException) throw e Toast.makeText(context, e.message ?: "Failed to reject", Toast.LENGTH_LONG).show() } inFlightSessions = inFlightSessions - proposal.sessionId @@ -233,6 +237,7 @@ fun WalletDescriptorScreen( }.onSuccess { showProposeDialog = false }.onFailure { e -> + if (e is CancellationException) throw e Toast.makeText(context, e.message ?: "Failed to propose descriptor", Toast.LENGTH_LONG).show() } } @@ -253,6 +258,7 @@ fun WalletDescriptorScreen( copySensitiveText(context, exported) Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() }.onFailure { e -> + if (e is CancellationException) throw e Toast.makeText(context, e.message ?: "Export failed", Toast.LENGTH_LONG).show() } showExportDialog = null @@ -275,6 +281,7 @@ fun WalletDescriptorScreen( }.onSuccess { showDeleteConfirm = null }.onFailure { e -> + if (e is CancellationException) throw e Toast.makeText(context, e.message ?: "Delete failed", Toast.LENGTH_LONG).show() } } From 791581f1dc65574e89c3503b7fe07748b8fa9fd3 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 19:06:06 -0500 Subject: [PATCH 09/22] Deduplicate pending proposals and guard descriptor polling --- app/src/main/kotlin/io/privkey/keep/MainActivity.kt | 2 +- .../io/privkey/keep/descriptor/WalletDescriptorScreen.kt | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index d6be3bef..9db0fa6b 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -410,7 +410,7 @@ fun MainScreen( val k = storage.getActiveShareKey() val p = if (h) keepMobile.getPeers() else emptyList() 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) } hasShare = newHasShare diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index ab67e27b..0c3887b6 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -66,7 +66,9 @@ object DescriptorSessionManager { } 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) } From e2998b231102eda8cbc7cd826585ac97ec6713b7 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 19:13:08 -0500 Subject: [PATCH 10/22] fix: thread safety, error handling, and ordering in descriptor code --- .../kotlin/io/privkey/keep/MainActivity.kt | 12 ++--- .../keep/descriptor/WalletDescriptorScreen.kt | 47 ++++++++++++------- .../DescriptorSessionManagerTest.kt | 4 +- 3 files changed, 37 insertions(+), 26 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index 9db0fa6b..8744512c 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -59,6 +59,7 @@ import io.privkey.keep.uniffi.BunkerStatus import io.privkey.keep.uniffi.KeepMobile import io.privkey.keep.uniffi.PeerInfo import io.privkey.keep.uniffi.ShareInfo +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.StateFlow @@ -380,12 +381,6 @@ fun MainScreen( withContext(Dispatchers.IO) { profileRelayConfigStore?.setRelaysForAccount(key, updated) } } - LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks()) - } - } - LaunchedEffect(Unit) { val initial = withContext(Dispatchers.IO) { val a = storage.listAllShares().map { it.toAccountInfo() } @@ -401,6 +396,9 @@ fun MainScreen( } LaunchedEffect(Unit) { + withContext(Dispatchers.IO) { + keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks()) + } lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { repeat(Int.MAX_VALUE) { val (newHasShare, newShareInfo, newAccounts, newActiveKey, newPeers, newPendingCount, newDescriptorCount) = withContext(Dispatchers.IO) { @@ -410,7 +408,7 @@ fun MainScreen( val k = storage.getActiveShareKey() val p = if (h) keepMobile.getPeers() else emptyList() val pc = if (h) keepMobile.getPendingRequests().size else 0 - val dc = if (h) runCatching { keepMobile.walletDescriptorList().size }.getOrDefault(0) else 0 + val dc = if (h) runCatching { keepMobile.walletDescriptorList().size }.onFailure { if (it is CancellationException) throw it }.getOrDefault(0) else 0 PollResult(h, s, a, k, p, pc, dc) } hasShare = newHasShare diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 0c3887b6..2c2926f9 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -1,5 +1,6 @@ package io.privkey.keep.descriptor +import android.util.Log import android.widget.Toast import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState @@ -25,10 +26,13 @@ import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import java.text.SimpleDateFormat -import java.util.Date +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter import java.util.Locale +private const val TAG = "WalletDescriptor" + private object ExportFormat { const val SPARROW = "sparrow" const val RAW = "raw" @@ -62,18 +66,18 @@ object DescriptorSessionManager { fun createCallbacks(): DescriptorCallbacks = object : DescriptorCallbacks { override fun onProposed(sessionId: String) { - _state.value = DescriptorSessionState.Proposed(sessionId) + _state.update { DescriptorSessionState.Proposed(sessionId) } } override fun onContributionNeeded(proposal: DescriptorProposal) { _pendingProposals.update { current -> if (current.any { it.sessionId == proposal.sessionId }) current else current + proposal } - _state.value = DescriptorSessionState.ContributionNeeded(proposal) + _state.update { DescriptorSessionState.ContributionNeeded(proposal) } } override fun onContributed(sessionId: String, shareIndex: UShort) { - _state.value = DescriptorSessionState.Contributed(sessionId, shareIndex) + _state.update { DescriptorSessionState.Contributed(sessionId, shareIndex) } } override fun onComplete( @@ -82,22 +86,23 @@ object DescriptorSessionManager { internalDescriptor: String ) { removePendingProposal(sessionId) - _state.value = DescriptorSessionState.Complete(sessionId, externalDescriptor, internalDescriptor) + _state.update { DescriptorSessionState.Complete(sessionId, externalDescriptor, internalDescriptor) } } override fun onFailed(sessionId: String, error: String) { removePendingProposal(sessionId) - _state.value = DescriptorSessionState.Failed(sessionId, error) + _state.update { DescriptorSessionState.Failed(sessionId, error) } } } fun clearSessionState() { - _state.value = DescriptorSessionState.Idle + _state.update { DescriptorSessionState.Idle } + _pendingProposals.update { emptyList() } } fun clearAll() { - _state.value = DescriptorSessionState.Idle - _pendingProposals.value = emptyList() + _state.update { DescriptorSessionState.Idle } + _pendingProposals.update { emptyList() } } fun removePendingProposal(sessionId: String) { @@ -180,7 +185,8 @@ fun WalletDescriptorScreen( DescriptorSessionManager.removePendingProposal(proposal.sessionId) }.onFailure { e -> if (e is CancellationException) throw e - Toast.makeText(context, e.message ?: "Failed to approve", Toast.LENGTH_LONG).show() + Log.w(TAG, "Failed to approve contribution", e) + Toast.makeText(context, "Failed to approve contribution", Toast.LENGTH_LONG).show() } inFlightSessions = inFlightSessions - proposal.sessionId } @@ -196,7 +202,8 @@ fun WalletDescriptorScreen( DescriptorSessionManager.removePendingProposal(proposal.sessionId) }.onFailure { e -> if (e is CancellationException) throw e - Toast.makeText(context, e.message ?: "Failed to reject", Toast.LENGTH_LONG).show() + Log.w(TAG, "Failed to reject contribution", e) + Toast.makeText(context, "Failed to reject contribution", Toast.LENGTH_LONG).show() } inFlightSessions = inFlightSessions - proposal.sessionId } @@ -240,7 +247,8 @@ fun WalletDescriptorScreen( showProposeDialog = false }.onFailure { e -> if (e is CancellationException) throw e - Toast.makeText(context, e.message ?: "Failed to propose descriptor", Toast.LENGTH_LONG).show() + Log.w(TAG, "Failed to propose descriptor", e) + Toast.makeText(context, "Failed to propose descriptor", Toast.LENGTH_LONG).show() } } }, @@ -261,7 +269,8 @@ fun WalletDescriptorScreen( Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() }.onFailure { e -> if (e is CancellationException) throw e - Toast.makeText(context, e.message ?: "Export failed", Toast.LENGTH_LONG).show() + Log.w(TAG, "Failed to export descriptor", e) + Toast.makeText(context, "Export failed", Toast.LENGTH_LONG).show() } showExportDialog = null } @@ -284,7 +293,8 @@ fun WalletDescriptorScreen( showDeleteConfirm = null }.onFailure { e -> if (e is CancellationException) throw e - Toast.makeText(context, e.message ?: "Delete failed", Toast.LENGTH_LONG).show() + Log.w(TAG, "Failed to delete descriptor", e) + Toast.makeText(context, "Delete failed", Toast.LENGTH_LONG).show() } } }, @@ -402,7 +412,10 @@ private fun DescriptorRow( onExport: (WalletDescriptorInfo) -> Unit, onDelete: (WalletDescriptorInfo) -> Unit ) { - val dateFormat = remember { SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()) } + val dateFormat = remember { + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + } Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { Text( @@ -417,7 +430,7 @@ private fun DescriptorRow( color = MaterialTheme.colorScheme.primary ) Text( - dateFormat.format(Date(descriptor.createdAt.toLong() * 1000)), + dateFormat.format(Instant.ofEpochSecond(descriptor.createdAt.toLong())), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt index c581250c..05caa636 100644 --- a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt +++ b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt @@ -123,14 +123,14 @@ class DescriptorSessionManagerTest { } @Test - fun `clearSessionState resets state but keeps proposals`() = runTest { + fun `clearSessionState resets state and proposals`() = runTest { val callbacks = DescriptorSessionManager.createCallbacks() callbacks.onContributionNeeded(makeProposal()) DescriptorSessionManager.clearSessionState() assertEquals(DescriptorSessionState.Idle, DescriptorSessionManager.state.first()) - assertEquals(1, DescriptorSessionManager.pendingProposals.first().size) + assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) } @Test From 3baf1e0555efeb8b4b9ddfbcb50f5d88f4efddc4 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 19:42:56 -0500 Subject: [PATCH 11/22] Clean up descriptor session state, callback resilience, and code simplification --- .../main/kotlin/io/privkey/keep/MainActivity.kt | 14 ++++++++++---- .../keep/descriptor/WalletDescriptorScreen.kt | 13 ++++++------- .../descriptor/DescriptorSessionManagerTest.kt | 8 ++++---- 3 files changed, 20 insertions(+), 15 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index 8744512c..e89e64c1 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -396,9 +396,11 @@ fun MainScreen( } LaunchedEffect(Unit) { - withContext(Dispatchers.IO) { - keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks()) - } + runCatching { + withContext(Dispatchers.IO) { + keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks()) + } + }.onFailure { if (it is CancellationException) throw it } lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { repeat(Int.MAX_VALUE) { val (newHasShare, newShareInfo, newAccounts, newActiveKey, newPeers, newPendingCount, newDescriptorCount) = withContext(Dispatchers.IO) { @@ -408,7 +410,11 @@ fun MainScreen( val k = storage.getActiveShareKey() val p = if (h) keepMobile.getPeers() else emptyList() val pc = if (h) keepMobile.getPendingRequests().size else 0 - val dc = if (h) runCatching { keepMobile.walletDescriptorList().size }.onFailure { if (it is CancellationException) throw it }.getOrDefault(0) else 0 + val dc = if (h) { + runCatching { keepMobile.walletDescriptorList().size } + .onFailure { if (it is CancellationException) throw it } + .getOrDefault(0) + } else 0 PollResult(h, s, a, k, p, pc, dc) } hasShare = newHasShare diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 2c2926f9..df97c33c 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -97,7 +97,6 @@ object DescriptorSessionManager { fun clearSessionState() { _state.update { DescriptorSessionState.Idle } - _pendingProposals.update { emptyList() } } fun clearAll() { @@ -505,14 +504,14 @@ private fun ProposeDescriptorDialog( } }, confirmButton = { - val t = threshold.toUIntOrNull() - val tl = timelockMonths.toUIntOrNull() - val valid = t != null && tl != null && t in 1u..15u && tl in 1u..120u + val parsedThreshold = threshold.toUIntOrNull() + val parsedTimelock = timelockMonths.toUIntOrNull() + val valid = parsedThreshold in 1u..15u && parsedTimelock in 1u..120u TextButton( onClick = { - val tv = threshold.toUIntOrNull() ?: return@TextButton - val tlv = timelockMonths.toUIntOrNull() ?: return@TextButton - if (tv in 1u..15u && tlv in 1u..120u) onPropose(network, listOf(RecoveryTierConfig(tv, tlv))) + if (parsedThreshold != null && parsedTimelock != null) { + onPropose(network, listOf(RecoveryTierConfig(parsedThreshold, parsedTimelock))) + } }, enabled = valid ) { diff --git a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt index 05caa636..e8bc72dd 100644 --- a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt +++ b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt @@ -123,14 +123,14 @@ class DescriptorSessionManagerTest { } @Test - fun `clearSessionState resets state and proposals`() = runTest { + fun `clearSessionState resets state but keeps proposals`() = runTest { val callbacks = DescriptorSessionManager.createCallbacks() callbacks.onContributionNeeded(makeProposal()) DescriptorSessionManager.clearSessionState() assertEquals(DescriptorSessionState.Idle, DescriptorSessionManager.state.first()) - assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) + assertEquals(1, DescriptorSessionManager.pendingProposals.first().size) } @Test @@ -157,8 +157,8 @@ class DescriptorSessionManagerTest { assertTrue(DescriptorSessionManager.state.first() is DescriptorSessionState.Contributed) callbacks.onComplete("s1", "ext-desc", "int-desc") - val final_ = DescriptorSessionManager.state.first() as DescriptorSessionState.Complete - assertEquals("ext-desc", final_.externalDescriptor) + val completeState = DescriptorSessionManager.state.first() as DescriptorSessionState.Complete + assertEquals("ext-desc", completeState.externalDescriptor) assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) } From 2c42d1ae26f4bb4538f37b5b7abb085bed9ba40d Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Tue, 24 Feb 2026 20:10:20 -0500 Subject: [PATCH 12/22] Log descriptor callback registration failures --- app/src/main/kotlin/io/privkey/keep/MainActivity.kt | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index e89e64c1..08ee8b3a 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -400,7 +400,10 @@ fun MainScreen( withContext(Dispatchers.IO) { keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks()) } - }.onFailure { if (it is CancellationException) throw it } + }.onFailure { + if (it is CancellationException) throw it + Log.e("MainActivity", "Failed to set descriptor callbacks", it) + } lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { repeat(Int.MAX_VALUE) { val (newHasShare, newShareInfo, newAccounts, newActiveKey, newPeers, newPendingCount, newDescriptorCount) = withContext(Dispatchers.IO) { From b09c37785c1235513785dfce2201a26d444bdb31 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 11:25:45 -0500 Subject: [PATCH 13/22] Fix security and code quality issues from PR review --- .../kotlin/io/privkey/keep/MainActivity.kt | 3 + .../keep/descriptor/WalletDescriptorScreen.kt | 82 ++++++++++++++++--- .../DescriptorSessionManagerTest.kt | 11 +++ 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index 08ee8b3a..d086ce1f 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -400,9 +400,12 @@ fun MainScreen( withContext(Dispatchers.IO) { keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks()) } + }.onSuccess { + DescriptorSessionManager.setCallbacksRegistered(true) }.onFailure { if (it is CancellationException) throw it Log.e("MainActivity", "Failed to set descriptor callbacks", it) + DescriptorSessionManager.setCallbacksRegistered(false) } lifecycleOwner.lifecycle.repeatOnLifecycle(Lifecycle.State.STARTED) { repeat(Int.MAX_VALUE) { diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index df97c33c..c0cdbe4b 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -64,6 +64,13 @@ object DescriptorSessionManager { private val _pendingProposals = MutableStateFlow>(emptyList()) val pendingProposals: StateFlow> = _pendingProposals.asStateFlow() + private val _callbacksRegistered = MutableStateFlow(false) + val callbacksRegistered: StateFlow = _callbacksRegistered.asStateFlow() + + fun setCallbacksRegistered(registered: Boolean) { + _callbacksRegistered.update { registered } + } + fun createCallbacks(): DescriptorCallbacks = object : DescriptorCallbacks { override fun onProposed(sessionId: String) { _state.update { DescriptorSessionState.Proposed(sessionId) } @@ -121,8 +128,12 @@ fun WalletDescriptorScreen( var showExportDialog by remember { mutableStateOf(null) } var showDeleteConfirm by remember { mutableStateOf(null) } var inFlightSessions by remember { mutableStateOf(emptySet()) } + var isProposing by remember { mutableStateOf(false) } + var isExporting by remember { mutableStateOf(false) } + var isDeleting by remember { mutableStateOf(false) } val sessionState by DescriptorSessionManager.state.collectAsState() val pendingProposals by DescriptorSessionManager.pendingProposals.collectAsState() + val callbacksRegistered by DescriptorSessionManager.callbacksRegistered.collectAsState() fun refreshDescriptors() { scope.launch { @@ -151,7 +162,7 @@ fun WalletDescriptorScreen( setSecureScreen(context, true) onDispose { setSecureScreen(context, false) - DescriptorSessionManager.clearSessionState() + DescriptorSessionManager.clearAll() } } @@ -166,6 +177,23 @@ fun WalletDescriptorScreen( Text("Wallet Descriptors", style = MaterialTheme.typography.headlineMedium) Spacer(modifier = Modifier.height(16.dp)) + if (!callbacksRegistered) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Text( + "Real-time updates unavailable. Propose and list operations still work.", + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodySmall + ) + } + Spacer(modifier = Modifier.height(16.dp)) + } + SessionStatusCard(sessionState) if (pendingProposals.isNotEmpty()) { @@ -181,12 +209,12 @@ fun WalletDescriptorScreen( withContext(Dispatchers.IO) { keepMobile.walletDescriptorApproveContribution(proposal.sessionId) } - DescriptorSessionManager.removePendingProposal(proposal.sessionId) }.onFailure { e -> if (e is CancellationException) throw e Log.w(TAG, "Failed to approve contribution", e) - Toast.makeText(context, "Failed to approve contribution", Toast.LENGTH_LONG).show() + Toast.makeText(context, "Failed to approve: ${truncateText(e.message ?: "unknown error", 80)}", Toast.LENGTH_LONG).show() } + DescriptorSessionManager.removePendingProposal(proposal.sessionId) inFlightSessions = inFlightSessions - proposal.sessionId } }, @@ -198,12 +226,12 @@ fun WalletDescriptorScreen( withContext(Dispatchers.IO) { keepMobile.walletDescriptorCancel(proposal.sessionId) } - DescriptorSessionManager.removePendingProposal(proposal.sessionId) }.onFailure { e -> if (e is CancellationException) throw e Log.w(TAG, "Failed to reject contribution", e) - Toast.makeText(context, "Failed to reject contribution", Toast.LENGTH_LONG).show() + Toast.makeText(context, "Failed to reject: ${truncateText(e.message ?: "unknown error", 80)}", Toast.LENGTH_LONG).show() } + DescriptorSessionManager.removePendingProposal(proposal.sessionId) inFlightSessions = inFlightSessions - proposal.sessionId } } @@ -236,7 +264,10 @@ fun WalletDescriptorScreen( if (showProposeDialog) { ProposeDescriptorDialog( + isProposing = isProposing, onPropose = { network, tiers -> + if (isProposing) return@ProposeDescriptorDialog + isProposing = true scope.launch { runCatching { withContext(Dispatchers.IO) { @@ -249,6 +280,7 @@ fun WalletDescriptorScreen( Log.w(TAG, "Failed to propose descriptor", e) Toast.makeText(context, "Failed to propose descriptor", Toast.LENGTH_LONG).show() } + isProposing = false } }, onDismiss = { showProposeDialog = false } @@ -258,7 +290,10 @@ fun WalletDescriptorScreen( showExportDialog?.let { descriptor -> ExportDescriptorDialog( descriptor = descriptor, + isExporting = isExporting, onExport = { format -> + if (isExporting) return@ExportDescriptorDialog + isExporting = true scope.launch { runCatching { val exported = withContext(Dispatchers.IO) { @@ -271,6 +306,7 @@ fun WalletDescriptorScreen( Log.w(TAG, "Failed to export descriptor", e) Toast.makeText(context, "Export failed", Toast.LENGTH_LONG).show() } + isExporting = false showExportDialog = null } }, @@ -281,20 +317,24 @@ fun WalletDescriptorScreen( showDeleteConfirm?.let { descriptor -> DeleteDescriptorDialog( descriptor = descriptor, + isDeleting = isDeleting, onConfirm = { + if (isDeleting) return@DeleteDescriptorDialog + isDeleting = true scope.launch { runCatching { withContext(Dispatchers.IO) { keepMobile.walletDescriptorDelete(descriptor.groupPubkey) } - refreshDescriptors() }.onSuccess { showDeleteConfirm = null + refreshDescriptors() }.onFailure { e -> if (e is CancellationException) throw e Log.w(TAG, "Failed to delete descriptor", e) Toast.makeText(context, "Delete failed", Toast.LENGTH_LONG).show() } + isDeleting = false } }, onDismiss = { showDeleteConfirm = null } @@ -460,6 +500,7 @@ private fun DescriptorRow( @Composable private fun ProposeDescriptorDialog( + isProposing: Boolean = false, onPropose: (String, List) -> Unit, onDismiss: () -> Unit ) { @@ -486,18 +527,32 @@ private fun ProposeDescriptorDialog( Spacer(modifier = Modifier.height(16.dp)) Text("Recovery Tier", style = MaterialTheme.typography.labelMedium) Spacer(modifier = Modifier.height(8.dp)) + val thresholdError = threshold.isNotEmpty() && threshold.toUIntOrNull()?.let { it !in 1u..15u } == true OutlinedTextField( value = threshold, onValueChange = { threshold = it.filter { c -> c.isDigit() } }, label = { Text("Threshold (1–15)") }, + isError = thresholdError || (threshold.isEmpty()), + supportingText = when { + threshold.isEmpty() -> {{ Text("Required") }} + thresholdError -> {{ Text("Must be between 1 and 15") }} + else -> null + }, singleLine = true, modifier = Modifier.fillMaxWidth() ) Spacer(modifier = Modifier.height(8.dp)) + val timelockError = timelockMonths.isNotEmpty() && timelockMonths.toUIntOrNull()?.let { it !in 1u..120u } == true OutlinedTextField( value = timelockMonths, onValueChange = { timelockMonths = it.filter { c -> c.isDigit() } }, label = { Text("Timelock months (1–120)") }, + isError = timelockError || (timelockMonths.isEmpty()), + supportingText = when { + timelockMonths.isEmpty() -> {{ Text("Required") }} + timelockError -> {{ Text("Must be between 1 and 120") }} + else -> null + }, singleLine = true, modifier = Modifier.fillMaxWidth() ) @@ -513,9 +568,9 @@ private fun ProposeDescriptorDialog( onPropose(network, listOf(RecoveryTierConfig(parsedThreshold, parsedTimelock))) } }, - enabled = valid + enabled = valid && !isProposing ) { - Text("Propose") + Text(if (isProposing) "Proposing..." else "Propose") } }, dismissButton = { @@ -527,6 +582,7 @@ private fun ProposeDescriptorDialog( @Composable private fun ExportDescriptorDialog( descriptor: WalletDescriptorInfo, + isExporting: Boolean = false, onExport: (String) -> Unit, onDismiss: () -> Unit ) { @@ -541,13 +597,13 @@ private fun ExportDescriptorDialog( color = MaterialTheme.colorScheme.onSurfaceVariant ) Spacer(modifier = Modifier.height(8.dp)) - Text("Choose export format:") + Text(if (isExporting) "Exporting..." else "Choose export format:") } }, confirmButton = { Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - TextButton(onClick = { onExport(ExportFormat.SPARROW) }) { Text("Sparrow") } - TextButton(onClick = { onExport(ExportFormat.RAW) }) { Text("Raw") } + TextButton(onClick = { onExport(ExportFormat.SPARROW) }, enabled = !isExporting) { Text("Sparrow") } + TextButton(onClick = { onExport(ExportFormat.RAW) }, enabled = !isExporting) { Text("Raw") } } }, dismissButton = { @@ -559,6 +615,7 @@ private fun ExportDescriptorDialog( @Composable private fun DeleteDescriptorDialog( descriptor: WalletDescriptorInfo, + isDeleting: Boolean = false, onConfirm: () -> Unit, onDismiss: () -> Unit ) { @@ -571,11 +628,12 @@ private fun DeleteDescriptorDialog( confirmButton = { TextButton( onClick = onConfirm, + enabled = !isDeleting, colors = ButtonDefaults.textButtonColors( contentColor = MaterialTheme.colorScheme.error ) ) { - Text("Delete") + Text(if (isDeleting) "Deleting..." else "Delete") } }, dismissButton = { diff --git a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt index e8bc72dd..c2f99243 100644 --- a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt +++ b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt @@ -5,6 +5,7 @@ import io.privkey.keep.uniffi.RecoveryTierConfig import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test @@ -14,6 +15,7 @@ class DescriptorSessionManagerTest { @Before fun setup() { DescriptorSessionManager.clearAll() + DescriptorSessionManager.setCallbacksRegistered(false) } private fun makeProposal( @@ -162,6 +164,15 @@ class DescriptorSessionManagerTest { assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) } + @Test + fun `callbacksRegistered defaults to false and can be set`() = runTest { + assertFalse(DescriptorSessionManager.callbacksRegistered.first()) + DescriptorSessionManager.setCallbacksRegistered(true) + assertTrue(DescriptorSessionManager.callbacksRegistered.first()) + DescriptorSessionManager.setCallbacksRegistered(false) + assertFalse(DescriptorSessionManager.callbacksRegistered.first()) + } + @Test fun `reject flow transitions through Failed`() = runTest { val callbacks = DescriptorSessionManager.createCallbacks() From 935ff0a16fecf6f5219521b59b857b81d5130546 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 11:32:04 -0500 Subject: [PATCH 14/22] Simplify descriptor state updates and deduplicate proposal actions --- .../keep/descriptor/WalletDescriptorScreen.kt | 79 +++++++++---------- .../DescriptorSessionManagerTest.kt | 15 ++-- 2 files changed, 44 insertions(+), 50 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index c0cdbe4b..f457ad55 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -68,23 +68,23 @@ object DescriptorSessionManager { val callbacksRegistered: StateFlow = _callbacksRegistered.asStateFlow() fun setCallbacksRegistered(registered: Boolean) { - _callbacksRegistered.update { registered } + _callbacksRegistered.value = registered } fun createCallbacks(): DescriptorCallbacks = object : DescriptorCallbacks { override fun onProposed(sessionId: String) { - _state.update { DescriptorSessionState.Proposed(sessionId) } + _state.value = DescriptorSessionState.Proposed(sessionId) } override fun onContributionNeeded(proposal: DescriptorProposal) { _pendingProposals.update { current -> if (current.any { it.sessionId == proposal.sessionId }) current else current + proposal } - _state.update { DescriptorSessionState.ContributionNeeded(proposal) } + _state.value = DescriptorSessionState.ContributionNeeded(proposal) } override fun onContributed(sessionId: String, shareIndex: UShort) { - _state.update { DescriptorSessionState.Contributed(sessionId, shareIndex) } + _state.value = DescriptorSessionState.Contributed(sessionId, shareIndex) } override fun onComplete( @@ -93,22 +93,22 @@ object DescriptorSessionManager { internalDescriptor: String ) { removePendingProposal(sessionId) - _state.update { DescriptorSessionState.Complete(sessionId, externalDescriptor, internalDescriptor) } + _state.value = DescriptorSessionState.Complete(sessionId, externalDescriptor, internalDescriptor) } override fun onFailed(sessionId: String, error: String) { removePendingProposal(sessionId) - _state.update { DescriptorSessionState.Failed(sessionId, error) } + _state.value = DescriptorSessionState.Failed(sessionId, error) } } fun clearSessionState() { - _state.update { DescriptorSessionState.Idle } + _state.value = DescriptorSessionState.Idle } fun clearAll() { - _state.update { DescriptorSessionState.Idle } - _pendingProposals.update { emptyList() } + _state.value = DescriptorSessionState.Idle + _pendingProposals.value = emptyList() } fun removePendingProposal(sessionId: String) { @@ -198,43 +198,36 @@ fun WalletDescriptorScreen( if (pendingProposals.isNotEmpty()) { Spacer(modifier = Modifier.height(16.dp)) + + fun handleProposalAction( + proposal: DescriptorProposal, + action: String, + block: suspend (String) -> Unit + ) { + if (proposal.sessionId in inFlightSessions) return + inFlightSessions = inFlightSessions + proposal.sessionId + scope.launch { + runCatching { + withContext(Dispatchers.IO) { block(proposal.sessionId) } + }.onFailure { e -> + if (e is CancellationException) throw e + Log.w(TAG, "Failed to $action contribution", e) + Toast.makeText(context, "Failed to $action: ${truncateText(e.message ?: "unknown error", 80)}", Toast.LENGTH_LONG).show() + } + DescriptorSessionManager.removePendingProposal(proposal.sessionId) + inFlightSessions = inFlightSessions - proposal.sessionId + } + } + PendingContributionsCard( proposals = pendingProposals, inFlightSessions = inFlightSessions, - onApprove = { proposal -> - if (proposal.sessionId in inFlightSessions) return@PendingContributionsCard - inFlightSessions = inFlightSessions + proposal.sessionId - scope.launch { - runCatching { - withContext(Dispatchers.IO) { - keepMobile.walletDescriptorApproveContribution(proposal.sessionId) - } - }.onFailure { e -> - if (e is CancellationException) throw e - Log.w(TAG, "Failed to approve contribution", e) - Toast.makeText(context, "Failed to approve: ${truncateText(e.message ?: "unknown error", 80)}", Toast.LENGTH_LONG).show() - } - DescriptorSessionManager.removePendingProposal(proposal.sessionId) - inFlightSessions = inFlightSessions - proposal.sessionId - } - }, - onReject = { proposal -> - if (proposal.sessionId in inFlightSessions) return@PendingContributionsCard - inFlightSessions = inFlightSessions + proposal.sessionId - scope.launch { - runCatching { - withContext(Dispatchers.IO) { - keepMobile.walletDescriptorCancel(proposal.sessionId) - } - }.onFailure { e -> - if (e is CancellationException) throw e - Log.w(TAG, "Failed to reject contribution", e) - Toast.makeText(context, "Failed to reject: ${truncateText(e.message ?: "unknown error", 80)}", Toast.LENGTH_LONG).show() - } - DescriptorSessionManager.removePendingProposal(proposal.sessionId) - inFlightSessions = inFlightSessions - proposal.sessionId - } - } + onApprove = { handleProposalAction(it, "approve") { id -> + keepMobile.walletDescriptorApproveContribution(id) + }}, + onReject = { handleProposalAction(it, "reject") { id -> + keepMobile.walletDescriptorCancel(id) + }} ) } diff --git a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt index c2f99243..0e791638 100644 --- a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt +++ b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt @@ -49,10 +49,10 @@ class DescriptorSessionManagerTest { fun `onContributed transitions to Contributed`() = runTest { val callbacks = DescriptorSessionManager.createCallbacks() callbacks.onContributed("session-1", 0u) - val state = DescriptorSessionManager.state.first() - assertTrue(state is DescriptorSessionState.Contributed) - assertEquals("session-1", (state as DescriptorSessionState.Contributed).sessionId) - assertEquals(0.toUShort(), state.shareIndex) + assertEquals( + DescriptorSessionState.Contributed("session-1", 0u), + DescriptorSessionManager.state.first() + ) } @Test @@ -179,9 +179,10 @@ class DescriptorSessionManagerTest { callbacks.onContributionNeeded(makeProposal("s1")) callbacks.onFailed("s1", "rejected by user") - val state = DescriptorSessionManager.state.first() - assertTrue(state is DescriptorSessionState.Failed) - assertEquals("rejected by user", (state as DescriptorSessionState.Failed).error) + assertEquals( + DescriptorSessionState.Failed("s1", "rejected by user"), + DescriptorSessionManager.state.first() + ) assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) } } From 798dd0302ef3e31065934278e7e419cff8b3386e Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 11:49:48 -0500 Subject: [PATCH 15/22] Fix proposal cleanup and descriptor count on transient failures --- app/src/main/kotlin/io/privkey/keep/MainActivity.kt | 2 +- .../io/privkey/keep/descriptor/WalletDescriptorScreen.kt | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index d086ce1f..34f14676 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -419,7 +419,7 @@ fun MainScreen( val dc = if (h) { runCatching { keepMobile.walletDescriptorList().size } .onFailure { if (it is CancellationException) throw it } - .getOrDefault(0) + .getOrDefault(descriptorCount) } else 0 PollResult(h, s, a, k, p, pc, dc) } diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index f457ad55..450e09cb 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -209,12 +209,13 @@ fun WalletDescriptorScreen( scope.launch { runCatching { withContext(Dispatchers.IO) { block(proposal.sessionId) } + }.onSuccess { + DescriptorSessionManager.removePendingProposal(proposal.sessionId) }.onFailure { e -> if (e is CancellationException) throw e Log.w(TAG, "Failed to $action contribution", e) Toast.makeText(context, "Failed to $action: ${truncateText(e.message ?: "unknown error", 80)}", Toast.LENGTH_LONG).show() } - DescriptorSessionManager.removePendingProposal(proposal.sessionId) inFlightSessions = inFlightSessions - proposal.sessionId } } From 94b1fdf77a857d73d6aa07b9302e23c15ef539f1 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 12:08:23 -0500 Subject: [PATCH 16/22] Implement onXpubAnnounced callback --- .../io/privkey/keep/descriptor/WalletDescriptorScreen.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 450e09cb..3463e3c4 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -17,6 +17,7 @@ import io.privkey.keep.uniffi.DescriptorCallbacks import io.privkey.keep.uniffi.DescriptorProposal import io.privkey.keep.uniffi.KeepMobile import io.privkey.keep.uniffi.RecoveryTierConfig +import io.privkey.keep.uniffi.AnnouncedXpubInfo import io.privkey.keep.uniffi.WalletDescriptorInfo import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers @@ -87,6 +88,10 @@ object DescriptorSessionManager { _state.value = DescriptorSessionState.Contributed(sessionId, shareIndex) } + override fun onXpubAnnounced(shareIndex: UShort, xpubs: List) { + Log.d(TAG, "Xpub announced for share $shareIndex: ${xpubs.size} xpub(s)") + } + override fun onComplete( sessionId: String, externalDescriptor: String, From 2d61aff861a65e4d974ab64bde30bc07d6a190da Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 12:21:29 -0500 Subject: [PATCH 17/22] Fix supportingText double-brace lambda syntax --- .../io/privkey/keep/descriptor/WalletDescriptorScreen.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 3463e3c4..36a5b83d 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -533,8 +533,8 @@ private fun ProposeDescriptorDialog( label = { Text("Threshold (1–15)") }, isError = thresholdError || (threshold.isEmpty()), supportingText = when { - threshold.isEmpty() -> {{ Text("Required") }} - thresholdError -> {{ Text("Must be between 1 and 15") }} + threshold.isEmpty() -> { Text("Required") } + thresholdError -> { Text("Must be between 1 and 15") } else -> null }, singleLine = true, @@ -548,8 +548,8 @@ private fun ProposeDescriptorDialog( label = { Text("Timelock months (1–120)") }, isError = timelockError || (timelockMonths.isEmpty()), supportingText = when { - timelockMonths.isEmpty() -> {{ Text("Required") }} - timelockError -> {{ Text("Must be between 1 and 120") }} + timelockMonths.isEmpty() -> { Text("Required") } + timelockError -> { Text("Must be between 1 and 120") } else -> null }, singleLine = true, From 2f120f7288d2d63017c7fb4608ef5b7487369965 Mon Sep 17 00:00:00 2001 From: "William K. Santiago" Date: Wed, 25 Feb 2026 12:34:05 -0500 Subject: [PATCH 18/22] Revert supportingText lambda syntax change --- .../io/privkey/keep/descriptor/WalletDescriptorScreen.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 36a5b83d..3463e3c4 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -533,8 +533,8 @@ private fun ProposeDescriptorDialog( label = { Text("Threshold (1–15)") }, isError = thresholdError || (threshold.isEmpty()), supportingText = when { - threshold.isEmpty() -> { Text("Required") } - thresholdError -> { Text("Must be between 1 and 15") } + threshold.isEmpty() -> {{ Text("Required") }} + thresholdError -> {{ Text("Must be between 1 and 15") }} else -> null }, singleLine = true, @@ -548,8 +548,8 @@ private fun ProposeDescriptorDialog( label = { Text("Timelock months (1–120)") }, isError = timelockError || (timelockMonths.isEmpty()), supportingText = when { - timelockMonths.isEmpty() -> { Text("Required") } - timelockError -> { Text("Must be between 1 and 120") } + timelockMonths.isEmpty() -> {{ Text("Required") }} + timelockError -> {{ Text("Must be between 1 and 120") }} else -> null }, singleLine = true, From afb6fe6eeae1e7f5e4841bf788cd63339e06e43d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 25 Feb 2026 14:24:19 -0500 Subject: [PATCH 19/22] Fix security and reliability issues in wallet descriptor flow --- .../kotlin/io/privkey/keep/MainActivity.kt | 2 ++ .../keep/descriptor/WalletDescriptorScreen.kt | 28 ++++++++++++++----- .../DescriptorSessionManagerTest.kt | 18 ++++++++++++ 3 files changed, 41 insertions(+), 7 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt index 34f14676..5734ef3a 100644 --- a/app/src/main/kotlin/io/privkey/keep/MainActivity.kt +++ b/app/src/main/kotlin/io/privkey/keep/MainActivity.kt @@ -396,6 +396,8 @@ fun MainScreen( } LaunchedEffect(Unit) { + DescriptorSessionManager.clearAll() + DescriptorSessionManager.activate() runCatching { withContext(Dispatchers.IO) { keepMobile.walletDescriptorSetCallbacks(DescriptorSessionManager.createCallbacks()) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 3463e3c4..5607d2e7 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -68,16 +68,21 @@ object DescriptorSessionManager { private val _callbacksRegistered = MutableStateFlow(false) val callbacksRegistered: StateFlow = _callbacksRegistered.asStateFlow() + @Volatile + private var active = true + fun setCallbacksRegistered(registered: Boolean) { _callbacksRegistered.value = registered } fun createCallbacks(): DescriptorCallbacks = object : DescriptorCallbacks { override fun onProposed(sessionId: String) { + if (!active) return _state.value = DescriptorSessionState.Proposed(sessionId) } override fun onContributionNeeded(proposal: DescriptorProposal) { + if (!active) return _pendingProposals.update { current -> if (current.any { it.sessionId == proposal.sessionId }) current else current + proposal } @@ -85,6 +90,7 @@ object DescriptorSessionManager { } override fun onContributed(sessionId: String, shareIndex: UShort) { + if (!active) return _state.value = DescriptorSessionState.Contributed(sessionId, shareIndex) } @@ -97,11 +103,13 @@ object DescriptorSessionManager { externalDescriptor: String, internalDescriptor: String ) { + if (!active) return removePendingProposal(sessionId) _state.value = DescriptorSessionState.Complete(sessionId, externalDescriptor, internalDescriptor) } override fun onFailed(sessionId: String, error: String) { + if (!active) return removePendingProposal(sessionId) _state.value = DescriptorSessionState.Failed(sessionId, error) } @@ -112,10 +120,15 @@ object DescriptorSessionManager { } fun clearAll() { + active = false _state.value = DescriptorSessionState.Idle _pendingProposals.value = emptyList() } + fun activate() { + active = true + } + fun removePendingProposal(sessionId: String) { _pendingProposals.update { it.filter { p -> p.sessionId != sessionId } } } @@ -132,7 +145,8 @@ fun WalletDescriptorScreen( var showProposeDialog by remember { mutableStateOf(false) } var showExportDialog by remember { mutableStateOf(null) } var showDeleteConfirm by remember { mutableStateOf(null) } - var inFlightSessions by remember { mutableStateOf(emptySet()) } + val inFlightSessions = remember { MutableStateFlow(emptySet()) } + val inFlightSessionsState by inFlightSessions.collectAsState() var isProposing by remember { mutableStateOf(false) } var isExporting by remember { mutableStateOf(false) } var isDeleting by remember { mutableStateOf(false) } @@ -209,8 +223,8 @@ fun WalletDescriptorScreen( action: String, block: suspend (String) -> Unit ) { - if (proposal.sessionId in inFlightSessions) return - inFlightSessions = inFlightSessions + proposal.sessionId + if (proposal.sessionId in inFlightSessions.value) return + inFlightSessions.update { it + proposal.sessionId } scope.launch { runCatching { withContext(Dispatchers.IO) { block(proposal.sessionId) } @@ -219,15 +233,15 @@ fun WalletDescriptorScreen( }.onFailure { e -> if (e is CancellationException) throw e Log.w(TAG, "Failed to $action contribution", e) - Toast.makeText(context, "Failed to $action: ${truncateText(e.message ?: "unknown error", 80)}", Toast.LENGTH_LONG).show() + Toast.makeText(context, "Failed to $action contribution", Toast.LENGTH_LONG).show() } - inFlightSessions = inFlightSessions - proposal.sessionId + inFlightSessions.update { it - proposal.sessionId } } } PendingContributionsCard( proposals = pendingProposals, - inFlightSessions = inFlightSessions, + inFlightSessions = inFlightSessionsState, onApprove = { handleProposalAction(it, "approve") { id -> keepMobile.walletDescriptorApproveContribution(id) }}, @@ -468,7 +482,7 @@ private fun DescriptorRow( color = MaterialTheme.colorScheme.primary ) Text( - dateFormat.format(Instant.ofEpochSecond(descriptor.createdAt.toLong())), + dateFormat.format(Instant.ofEpochSecond(descriptor.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong())), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) diff --git a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt index 0e791638..282a3b43 100644 --- a/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt +++ b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt @@ -15,6 +15,7 @@ class DescriptorSessionManagerTest { @Before fun setup() { DescriptorSessionManager.clearAll() + DescriptorSessionManager.activate() DescriptorSessionManager.setCallbacksRegistered(false) } @@ -90,6 +91,7 @@ class DescriptorSessionManagerTest { val callbacks = DescriptorSessionManager.createCallbacks() listOf("bitcoin", "testnet", "signet").forEach { network -> DescriptorSessionManager.clearAll() + DescriptorSessionManager.activate() val proposal = makeProposal(sessionId = "session-$network", network = network) callbacks.onContributionNeeded(proposal) @@ -185,4 +187,20 @@ class DescriptorSessionManagerTest { ) assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) } + + @Test + fun `callbacks are ignored after clearAll deactivates`() = runTest { + val callbacks = DescriptorSessionManager.createCallbacks() + DescriptorSessionManager.clearAll() + + callbacks.onProposed("s1") + assertEquals(DescriptorSessionState.Idle, DescriptorSessionManager.state.first()) + + callbacks.onContributionNeeded(makeProposal("s2")) + assertTrue(DescriptorSessionManager.pendingProposals.first().isEmpty()) + + DescriptorSessionManager.activate() + callbacks.onProposed("s3") + assertEquals(DescriptorSessionState.Proposed("s3"), DescriptorSessionManager.state.first()) + } } From d249af4d83cc4b5027a735315654de2ec050fb16 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 25 Feb 2026 14:35:40 -0500 Subject: [PATCH 20/22] Simplify descriptor state and add missing active guard --- .../keep/descriptor/WalletDescriptorScreen.kt | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 5607d2e7..55756938 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -95,6 +95,7 @@ object DescriptorSessionManager { } override fun onXpubAnnounced(shareIndex: UShort, xpubs: List) { + if (!active) return Log.d(TAG, "Xpub announced for share $shareIndex: ${xpubs.size} xpub(s)") } @@ -145,8 +146,7 @@ fun WalletDescriptorScreen( var showProposeDialog by remember { mutableStateOf(false) } var showExportDialog by remember { mutableStateOf(null) } var showDeleteConfirm by remember { mutableStateOf(null) } - val inFlightSessions = remember { MutableStateFlow(emptySet()) } - val inFlightSessionsState by inFlightSessions.collectAsState() + var inFlightSessions by remember { mutableStateOf(emptySet()) } var isProposing by remember { mutableStateOf(false) } var isExporting by remember { mutableStateOf(false) } var isDeleting by remember { mutableStateOf(false) } @@ -215,33 +215,33 @@ fun WalletDescriptorScreen( SessionStatusCard(sessionState) - if (pendingProposals.isNotEmpty()) { - Spacer(modifier = Modifier.height(16.dp)) - - fun handleProposalAction( - proposal: DescriptorProposal, - action: String, - block: suspend (String) -> Unit - ) { - if (proposal.sessionId in inFlightSessions.value) return - inFlightSessions.update { it + proposal.sessionId } - scope.launch { - runCatching { - withContext(Dispatchers.IO) { block(proposal.sessionId) } - }.onSuccess { - DescriptorSessionManager.removePendingProposal(proposal.sessionId) - }.onFailure { e -> - if (e is CancellationException) throw e - Log.w(TAG, "Failed to $action contribution", e) - Toast.makeText(context, "Failed to $action contribution", Toast.LENGTH_LONG).show() - } - inFlightSessions.update { it - proposal.sessionId } + fun handleProposalAction( + proposal: DescriptorProposal, + action: String, + block: suspend (String) -> Unit + ) { + if (proposal.sessionId in inFlightSessions) return + inFlightSessions = inFlightSessions + proposal.sessionId + scope.launch { + runCatching { + withContext(Dispatchers.IO) { block(proposal.sessionId) } + }.onSuccess { + DescriptorSessionManager.removePendingProposal(proposal.sessionId) + }.onFailure { e -> + if (e is CancellationException) throw e + Log.w(TAG, "Failed to $action contribution", e) + Toast.makeText(context, "Failed to $action contribution", Toast.LENGTH_LONG).show() } + inFlightSessions = inFlightSessions - proposal.sessionId } + } + + if (pendingProposals.isNotEmpty()) { + Spacer(modifier = Modifier.height(16.dp)) PendingContributionsCard( proposals = pendingProposals, - inFlightSessions = inFlightSessionsState, + inFlightSessions = inFlightSessions, onApprove = { handleProposalAction(it, "approve") { id -> keepMobile.walletDescriptorApproveContribution(id) }}, @@ -482,7 +482,7 @@ private fun DescriptorRow( color = MaterialTheme.colorScheme.primary ) Text( - dateFormat.format(Instant.ofEpochSecond(descriptor.createdAt.coerceAtMost(Long.MAX_VALUE.toULong()).toLong())), + dateFormat.format(Instant.ofEpochSecond(descriptor.createdAt.toLong())), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant ) From f25d88f426ce97f5bbe4ea756ed62f9b390e8f6d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 25 Feb 2026 14:41:31 -0500 Subject: [PATCH 21/22] Fix callback reactivation on screen re-entry and supportingText lambdas --- .../keep/descriptor/WalletDescriptorScreen.kt | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 55756938..1a144cbe 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -179,6 +179,7 @@ fun WalletDescriptorScreen( DisposableEffect(Unit) { setSecureScreen(context, true) + DescriptorSessionManager.activate() onDispose { setSecureScreen(context, false) DescriptorSessionManager.clearAll() @@ -546,11 +547,11 @@ private fun ProposeDescriptorDialog( onValueChange = { threshold = it.filter { c -> c.isDigit() } }, label = { Text("Threshold (1–15)") }, isError = thresholdError || (threshold.isEmpty()), - supportingText = when { - threshold.isEmpty() -> {{ Text("Required") }} - thresholdError -> {{ Text("Must be between 1 and 15") }} - else -> null - }, + supportingText = if (threshold.isEmpty()) { + { Text("Required") } + } else if (thresholdError) { + { Text("Must be between 1 and 15") } + } else null, singleLine = true, modifier = Modifier.fillMaxWidth() ) @@ -561,11 +562,11 @@ private fun ProposeDescriptorDialog( onValueChange = { timelockMonths = it.filter { c -> c.isDigit() } }, label = { Text("Timelock months (1–120)") }, isError = timelockError || (timelockMonths.isEmpty()), - supportingText = when { - timelockMonths.isEmpty() -> {{ Text("Required") }} - timelockError -> {{ Text("Must be between 1 and 120") }} - else -> null - }, + supportingText = if (timelockMonths.isEmpty()) { + { Text("Required") } + } else if (timelockError) { + { Text("Must be between 1 and 120") } + } else null, singleLine = true, modifier = Modifier.fillMaxWidth() ) From 57057b1ced872a33fcd3818a1884f77083aca03e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kyle=20=F0=9F=90=86?= Date: Wed, 25 Feb 2026 14:55:38 -0500 Subject: [PATCH 22/22] Fix flag reset on CancellationException and remove redundant Idle guard --- .../keep/descriptor/WalletDescriptorScreen.kt | 75 ++++++++++--------- 1 file changed, 41 insertions(+), 34 deletions(-) diff --git a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt index 1a144cbe..6fd89e1e 100644 --- a/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -283,18 +283,21 @@ fun WalletDescriptorScreen( if (isProposing) return@ProposeDescriptorDialog isProposing = true scope.launch { - runCatching { - withContext(Dispatchers.IO) { - keepMobile.walletDescriptorPropose(network, tiers) + try { + runCatching { + withContext(Dispatchers.IO) { + keepMobile.walletDescriptorPropose(network, tiers) + } + }.onSuccess { + showProposeDialog = false + }.onFailure { e -> + if (e is CancellationException) throw e + Log.w(TAG, "Failed to propose descriptor", e) + Toast.makeText(context, "Failed to propose descriptor", Toast.LENGTH_LONG).show() } - }.onSuccess { - showProposeDialog = false - }.onFailure { e -> - if (e is CancellationException) throw e - Log.w(TAG, "Failed to propose descriptor", e) - Toast.makeText(context, "Failed to propose descriptor", Toast.LENGTH_LONG).show() + } finally { + isProposing = false } - isProposing = false } }, onDismiss = { showProposeDialog = false } @@ -309,19 +312,22 @@ fun WalletDescriptorScreen( if (isExporting) return@ExportDescriptorDialog isExporting = true scope.launch { - runCatching { - val exported = withContext(Dispatchers.IO) { - keepMobile.walletDescriptorExport(descriptor.groupPubkey, format) + try { + runCatching { + val exported = withContext(Dispatchers.IO) { + keepMobile.walletDescriptorExport(descriptor.groupPubkey, format) + } + copySensitiveText(context, exported) + Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() + }.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() } - copySensitiveText(context, exported) - Toast.makeText(context, "Copied to clipboard", Toast.LENGTH_SHORT).show() - }.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() + } finally { + isExporting = false + showExportDialog = null } - isExporting = false - showExportDialog = null } }, onDismiss = { showExportDialog = null } @@ -336,19 +342,22 @@ fun WalletDescriptorScreen( if (isDeleting) return@DeleteDescriptorDialog isDeleting = true scope.launch { - runCatching { - withContext(Dispatchers.IO) { - keepMobile.walletDescriptorDelete(descriptor.groupPubkey) + try { + runCatching { + withContext(Dispatchers.IO) { + keepMobile.walletDescriptorDelete(descriptor.groupPubkey) + } + }.onSuccess { + showDeleteConfirm = null + refreshDescriptors() + }.onFailure { e -> + if (e is CancellationException) throw e + Log.w(TAG, "Failed to delete descriptor", e) + Toast.makeText(context, "Delete failed", Toast.LENGTH_LONG).show() } - }.onSuccess { - showDeleteConfirm = null - refreshDescriptors() - }.onFailure { e -> - if (e is CancellationException) throw e - Log.w(TAG, "Failed to delete descriptor", e) - Toast.makeText(context, "Delete failed", Toast.LENGTH_LONG).show() + } finally { + isDeleting = false } - isDeleting = false } }, onDismiss = { showDeleteConfirm = null } @@ -358,8 +367,6 @@ fun WalletDescriptorScreen( @Composable private fun SessionStatusCard(state: DescriptorSessionState) { - if (state is DescriptorSessionState.Idle) return - val (statusText, statusColor) = when (state) { is DescriptorSessionState.Proposed -> "Proposed — waiting for contributions" to MaterialTheme.colorScheme.primary is DescriptorSessionState.ContributionNeeded -> "Contribution needed" to MaterialTheme.colorScheme.tertiary