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..5734ef3a 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 @@ -57,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 @@ -315,6 +318,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()) } @@ -391,16 +396,34 @@ fun MainScreen( } LaunchedEffect(Unit) { + DescriptorSessionManager.clearAll() + DescriptorSessionManager.activate() + runCatching { + 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) { - 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 = if (h) { + runCatching { keepMobile.walletDescriptorList().size } + .onFailure { if (it is CancellationException) throw it } + .getOrDefault(descriptorCount) + } else 0 + PollResult(h, s, a, k, p, pc, dc) } hasShare = newHasShare shareInfo = newShareInfo @@ -408,6 +431,7 @@ fun MainScreen( activeAccountKey = newActiveKey peers = newPeers pendingCount = newPendingCount + descriptorCount = newDescriptorCount refreshCertificatePins() profileRelays = withContext(Dispatchers.IO) { loadProfileRelays(newActiveKey) } delay(10_000) @@ -552,6 +576,14 @@ fun MainScreen( return } + if (showWalletDescriptorScreen) { + WalletDescriptorScreen( + keepMobile = keepMobile, + onDismiss = { showWalletDescriptorScreen = false } + ) + return + } + if (showAccountSwitcher) { AccountSwitcherSheet( accounts = allAccounts, @@ -696,11 +728,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 +940,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 +974,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 +1212,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..6fd89e1e --- /dev/null +++ b/app/src/main/kotlin/io/privkey/keep/descriptor/WalletDescriptorScreen.kt @@ -0,0 +1,664 @@ +package io.privkey.keep.descriptor + +import android.util.Log +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.AnnouncedXpubInfo +import io.privkey.keep.uniffi.WalletDescriptorInfo +import kotlinx.coroutines.CancellationException +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.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" +} + +private fun truncateText(text: String, maxLength: Int): String = + if (text.length <= maxLength) text else "${text.take(maxLength)}..." + +private fun truncateGroupPubkey(key: String): String = + if (key.length <= 14) key else "${key.take(8)}...${key.takeLast(6)}" + +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() + + 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 + } + _state.value = DescriptorSessionState.ContributionNeeded(proposal) + } + + override fun onContributed(sessionId: String, shareIndex: UShort) { + if (!active) return + _state.value = DescriptorSessionState.Contributed(sessionId, shareIndex) + } + + override fun onXpubAnnounced(shareIndex: UShort, xpubs: List) { + if (!active) return + Log.d(TAG, "Xpub announced for share $shareIndex: ${xpubs.size} xpub(s)") + } + + override fun onComplete( + sessionId: String, + 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) + } + } + + fun clearSessionState() { + _state.value = DescriptorSessionState.Idle + } + + 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 } } + } +} + +@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) } + 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 { + runCatching { + withContext(Dispatchers.IO) { keepMobile.walletDescriptorList() } + }.onSuccess { + descriptors = it + }.onFailure { + if (it is CancellationException) throw it + Toast.makeText(context, "Failed to load descriptors", Toast.LENGTH_SHORT).show() + } + } + } + + LaunchedEffect(Unit) { + refreshDescriptors() + } + + LaunchedEffect(sessionState) { + if (sessionState is DescriptorSessionState.Complete) { + refreshDescriptors() + } + } + + DisposableEffect(Unit) { + setSecureScreen(context, true) + DescriptorSessionManager.activate() + onDispose { + setSecureScreen(context, false) + DescriptorSessionManager.clearAll() + } + } + + 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)) + + 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) + + 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 = inFlightSessions, + onApprove = { handleProposalAction(it, "approve") { id -> + keepMobile.walletDescriptorApproveContribution(id) + }}, + onReject = { handleProposalAction(it, "reject") { id -> + keepMobile.walletDescriptorCancel(id) + }} + ) + } + + 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( + isProposing = isProposing, + onPropose = { network, tiers -> + if (isProposing) return@ProposeDescriptorDialog + isProposing = true + scope.launch { + 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() + } + } finally { + isProposing = false + } + } + }, + onDismiss = { showProposeDialog = false } + ) + } + + showExportDialog?.let { descriptor -> + ExportDescriptorDialog( + descriptor = descriptor, + isExporting = isExporting, + onExport = { format -> + if (isExporting) return@ExportDescriptorDialog + isExporting = true + scope.launch { + 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() + } + } finally { + isExporting = false + showExportDialog = null + } + } + }, + onDismiss = { showExportDialog = null } + ) + } + + showDeleteConfirm?.let { descriptor -> + DeleteDescriptorDialog( + descriptor = descriptor, + isDeleting = isDeleting, + onConfirm = { + if (isDeleting) return@DeleteDescriptorDialog + isDeleting = true + scope.launch { + 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() + } + } finally { + isDeleting = false + } + } + }, + onDismiss = { showDeleteConfirm = null } + ) + } +} + +@Composable +private fun SessionStatusCard(state: DescriptorSessionState) { + 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: ${truncateText(state.error, 80)}" 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: ${truncateText(state.externalDescriptor, 40)}", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + } + } +} + +@Composable +private fun PendingContributionsCard( + proposals: List, + inFlightSessions: Set, + 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)) { + val busy = proposal.sessionId in inFlightSessions + OutlinedButton(onClick = { onReject(proposal) }, enabled = !busy) { + Text("Reject") + } + Button(onClick = { onApprove(proposal) }, enabled = !busy) { + 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 { + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm", Locale.getDefault()) + .withZone(ZoneId.systemDefault()) + } + + Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) { + Text( + truncateGroupPubkey(descriptor.groupPubkey), + 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(Instant.ofEpochSecond(descriptor.createdAt.toLong())), + 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( + isProposing: Boolean = false, + 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)) + FlowRow(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)) + 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 = if (threshold.isEmpty()) { + { Text("Required") } + } else if (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 = if (timelockMonths.isEmpty()) { + { Text("Required") } + } else if (timelockError) { + { Text("Must be between 1 and 120") } + } else null, + singleLine = true, + modifier = Modifier.fillMaxWidth() + ) + } + }, + confirmButton = { + val parsedThreshold = threshold.toUIntOrNull() + val parsedTimelock = timelockMonths.toUIntOrNull() + val valid = parsedThreshold in 1u..15u && parsedTimelock in 1u..120u + TextButton( + onClick = { + if (parsedThreshold != null && parsedTimelock != null) { + onPropose(network, listOf(RecoveryTierConfig(parsedThreshold, parsedTimelock))) + } + }, + enabled = valid && !isProposing + ) { + Text(if (isProposing) "Proposing..." else "Propose") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +@Composable +private fun ExportDescriptorDialog( + descriptor: WalletDescriptorInfo, + isExporting: Boolean = false, + onExport: (String) -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Export Descriptor") }, + text = { + Column { + Text( + truncateGroupPubkey(descriptor.groupPubkey), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + Spacer(modifier = Modifier.height(8.dp)) + Text(if (isExporting) "Exporting..." else "Choose export format:") + } + }, + confirmButton = { + Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + TextButton(onClick = { onExport(ExportFormat.SPARROW) }, enabled = !isExporting) { Text("Sparrow") } + TextButton(onClick = { onExport(ExportFormat.RAW) }, enabled = !isExporting) { Text("Raw") } + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} + +@Composable +private fun DeleteDescriptorDialog( + descriptor: WalletDescriptorInfo, + isDeleting: Boolean = false, + onConfirm: () -> Unit, + onDismiss: () -> Unit +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text("Delete Descriptor?") }, + text = { + Text("This will permanently remove the wallet descriptor for ${truncateGroupPubkey(descriptor.groupPubkey)}") + }, + confirmButton = { + TextButton( + onClick = onConfirm, + enabled = !isDeleting, + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.error + ) + ) { + Text(if (isDeleting) "Deleting..." else "Delete") + } + }, + dismissButton = { + TextButton(onClick = onDismiss) { Text("Cancel") } + } + ) +} 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..282a3b43 --- /dev/null +++ b/app/src/test/kotlin/io/privkey/keep/descriptor/DescriptorSessionManagerTest.kt @@ -0,0 +1,206 @@ +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.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class DescriptorSessionManagerTest { + + @Before + fun setup() { + DescriptorSessionManager.clearAll() + DescriptorSessionManager.activate() + DescriptorSessionManager.setCallbacksRegistered(false) + } + + 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) + assertEquals( + DescriptorSessionState.Contributed("session-1", 0u), + DescriptorSessionManager.state.first() + ) + } + + @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() + DescriptorSessionManager.activate() + 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 completeState = DescriptorSessionManager.state.first() as DescriptorSessionState.Complete + assertEquals("ext-desc", completeState.externalDescriptor) + 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() + callbacks.onContributionNeeded(makeProposal("s1")) + callbacks.onFailed("s1", "rejected by user") + + assertEquals( + DescriptorSessionState.Failed("s1", "rejected by user"), + DescriptorSessionManager.state.first() + ) + 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()) + } +}