diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts b/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts index 0a4511fe85d..b5607f9e8a9 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts +++ b/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts @@ -82,6 +82,7 @@ dependencies { implementation(libs.camera.view) implementation(libs.mlkit.barcode.scanning) implementation(libs.zxing.core) + implementation(libs.coil.compose) testImplementation(libs.junit) testImplementation(libs.kotlinx.coroutines.test) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/AppSmokeTest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/AppSmokeTest.kt index d53585acdbe..557d6374ec2 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/AppSmokeTest.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/AppSmokeTest.kt @@ -34,7 +34,7 @@ class AppSmokeTest { listOf( "rootTab.wallets", "rootTab.identities", - "rootTab.contracts", + "rootTab.dashpay", "rootTab.settings", "rootTab.sync", ).forEach { tag -> diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/DashPayTabUITest.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/DashPayTabUITest.kt new file mode 100644 index 00000000000..29d2bea3ea4 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/DashPayTabUITest.kt @@ -0,0 +1,94 @@ +package org.dashfoundation.example + +import androidx.compose.ui.test.hasTestTag +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith + +/** + * DashPay tab smoke tests — port of `DashPayTabUITests.swift`, adapted for + * the Kotlin hub's structure (nav-section rows instead of a Contacts/Requests + * segmented control). Network-free: they assert only the local-state gating + * the tab renders (no wallet / no identity / identity present), keyed on the + * `dashpay.*` testTags reused verbatim from iOS. Runs against the real + * bootstrap, so it also proves end-to-end startup + tab navigation. + */ +@RunWith(AndroidJUnit4::class) +class DashPayTabUITest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + private fun anyNodeWithTag(tag: String): Boolean = + composeRule.onAllNodes(hasTestTag(tag)).fetchSemanticsNodes().isNotEmpty() + + private fun openDashPayTab() { + composeRule.waitUntil(timeoutMillis = 60_000) { + anyNodeWithTag("rootTab.dashpay") + } + composeRule.onNodeWithTag("rootTab.dashpay").performClick() + composeRule.waitForIdle() + } + + /** + * The DashPay tab must render exactly one recognized local state: + * 1. no wallet loaded → "Open Wallets" CTA (`dashpay.openWallets`) + * 2. wallet, no identity → "Open Identities" CTA (`dashpay.openIdentities`) + * 3. ≥1 eligible identity → the hub sections (`dashpay.openContacts`) + * On a fresh emulator state 1 is expected, but the test accepts any of the + * three so it stays valid with leftover local wallets — the invariant is + * "the tab renders a recognized state". + */ + @Test + fun dashPayTabRendersARecognizedState() { + openDashPayTab() + + composeRule.waitUntil(timeoutMillis = 30_000) { + anyNodeWithTag("dashpay.openWallets") || + anyNodeWithTag("dashpay.openIdentities") || + anyNodeWithTag("dashpay.openContacts") + } + + assertTrue( + "DashPay tab must render one of the states: no-wallet CTA, " + + "no-identity CTA, or the identity-present hub sections.", + anyNodeWithTag("dashpay.openWallets") || + anyNodeWithTag("dashpay.openIdentities") || + anyNodeWithTag("dashpay.openContacts"), + ) + } + + /** + * When an identity is active (state 3), the hub exposes the contact + * management + recovery entry points directly — Add Contact, Refresh, + * Ignored and Hidden. (In iOS these were toolbar buttons / a gated link; + * the Kotlin hub surfaces them as nav rows — the documented deviation.) + * Skipped when no eligible identity exists on this emulator. + */ + @Test + fun hubExposesContactEntryPointsWhenIdentityActive() { + openDashPayTab() + + composeRule.waitUntil(timeoutMillis = 30_000) { + anyNodeWithTag("dashpay.openWallets") || + anyNodeWithTag("dashpay.openIdentities") || + anyNodeWithTag("dashpay.openContacts") + } + + org.junit.Assume.assumeTrue( + "No eligible identity on this emulator — the hub sections are " + + "unreachable, so the entry points can't be asserted.", + anyNodeWithTag("dashpay.openContacts"), + ) + + assertTrue("Add Contact entry must be present", anyNodeWithTag("dashpay.addContact")) + assertTrue("Refresh must be present", anyNodeWithTag("dashpay.refresh")) + assertTrue("Ignored recovery entry must be present", anyNodeWithTag("dashpay.openIgnored")) + assertTrue("Hidden recovery entry must be present", anyNodeWithTag("dashpay.openHidden")) + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index 5bd47fa32bb..3497cf0c8f5 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt @@ -45,6 +45,16 @@ class AppContainer(private val context: Context) { /** Keystore-wrapped secret store (mnemonics + identity keys). */ val walletStorage = org.dashfoundation.dashsdk.security.WalletStorage(context) + /** + * Device-local DashPay contact metadata (alias / note / hidden / DPNS + * hint). A single shared instance so a write's `version` bump invalidates + * every DashPay screen that reads through it — a per-screen instance would + * only recompose its own creator. + */ + val dashPayContactMetaStore by lazy { + org.dashfoundation.example.ui.dashpay.DashPayContactMetaStore(context) + } + /** * Auth gate for secret reveals / out-of-window key access. The * container is Application-scoped, so the gate is a delegating shell; @@ -183,6 +193,7 @@ class AppContainer(private val context: Context) { if (shieldedService.isAvailable) { manager.stopShieldedSync() } + manager.stopDashPaySync() } catch (e: Exception) { android.util.Log.w(TAG, "Failed to stop sync coordinators", e) } @@ -209,6 +220,13 @@ class AppContainer(private val context: Context) { if (shieldedService.isAvailable && !manager.isShieldedSyncRunning()) { manager.startShieldedSync() } + // DashPay contact/profile/payment sync — the load-bearing + // prerequisite for the DashPay tab (← iOS + // rebindWalletScopedServices starting it alongside the + // address/shielded loops on the wallet-present branch). + if (!manager.isDashPaySyncRunning()) { + manager.startDashPaySync() + } } catch (e: Exception) { android.util.Log.e(TAG, "Failed to bind wallet-scoped services", e) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt index 05bb11f287c..2a862091559 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/AppNavHost.kt @@ -28,8 +28,15 @@ import org.dashfoundation.example.ui.diagnostics.WalletMemoryExplorerScreen import org.dashfoundation.example.ui.identity.AddIdentityKeyScreen import org.dashfoundation.example.ui.identity.ContestDetailScreen import org.dashfoundation.example.ui.identity.CreateIdentityScreen +import org.dashfoundation.example.ui.dashpay.AddContactScreen +import org.dashfoundation.example.ui.dashpay.ContactDetailScreen +import org.dashfoundation.example.ui.dashpay.ContactRequestsScreen +import org.dashfoundation.example.ui.dashpay.ContactsScreen +import org.dashfoundation.example.ui.dashpay.DashPayProfileScreen +import org.dashfoundation.example.ui.dashpay.DashPayTabScreen +import org.dashfoundation.example.ui.dashpay.HiddenContactsScreen +import org.dashfoundation.example.ui.dashpay.IgnoredContactsScreen import org.dashfoundation.example.ui.identity.DpnsTestScreen -import org.dashfoundation.example.ui.identity.FriendsScreen import org.dashfoundation.example.ui.identity.IdentitiesHomeScreen import org.dashfoundation.example.ui.identity.IdentityDetailScreen import org.dashfoundation.example.ui.identity.KeyDetailScreen @@ -164,11 +171,6 @@ fun AppNavHost( SelectMainNameScreen(route.identityIdHex, navController) } - composable { entry -> - val route = entry.toRoute() - FriendsScreen(route.identityIdHex, navController) - } - composable { entry -> val route = entry.toRoute() KeysListScreen(route.identityIdHex, navController) @@ -366,6 +368,39 @@ fun AppNavHost( CoSignProposalScreen(entry.toRoute(), navController) } + // ── DashPay graph ────────────────────────────────────────────── + + composable { DashPayTabScreen(navController) } + + composable { entry -> + ContactsScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + ContactRequestsScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + AddContactScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + val route = entry.toRoute() + ContactDetailScreen(route.identityIdHex, route.contactIdHex, navController) + } + + composable { entry -> + DashPayProfileScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + IgnoredContactsScreen(entry.toRoute().identityIdHex, navController) + } + + composable { entry -> + HiddenContactsScreen(entry.toRoute().ownerIdentityIdHex, navController) + } + // ── Diagnostics graph ────────────────────────────────────────── composable { AddressQueriesScreen(navController) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt index 23c37bd28d3..dba982f8794 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/navigation/Routes.kt @@ -18,6 +18,8 @@ import kotlinx.serialization.Serializable @Serializable object ContractsHome +@Serializable object DashPayHome + @Serializable object SettingsHome // ── Settings graph ───────────────────────────────────────────────────── @@ -89,9 +91,6 @@ import kotlinx.serialization.Serializable /** Pick the identity's main DPNS name (← `SelectMainNameView.swift`). */ @Serializable data class SelectMainName(val identityIdHex: String) -/** DashPay contacts for an identity (← `FriendsView.swift`). */ -@Serializable data class Friends(val identityIdHex: String) - /** All public keys of an identity (← `KeysListView.swift`). */ @Serializable data class KeysList(val identityIdHex: String) @@ -174,6 +173,32 @@ import kotlinx.serialization.Serializable /** Shielded activity timeline for a wallet (← `ShieldedActivityView.swift`). */ @Serializable data class ShieldedActivity(val walletIdHex: String) +// ── DashPay graph (hosted under the DashPay tab, ← the DashPay/ views) ── + +/** Established-contacts list for an identity (← `ContactsView.swift`). */ +@Serializable data class DashPayContacts(val identityIdHex: String) + +/** Incoming + outgoing contact requests (← `ContactRequestsView.swift`). */ +@Serializable data class DashPayRequests(val identityIdHex: String) + +/** Add-a-contact form (DPNS search / paste id / QR, ← `AddContactView.swift`). */ +@Serializable data class DashPayAddContact(val identityIdHex: String) + +/** One contact's detail + payments (← `ContactDetailView.swift`). */ +@Serializable data class DashPayContactDetail( + val identityIdHex: String, + val contactIdHex: String, +) + +/** Read-only own-profile sheet (← `DashPayProfileView.swift`). */ +@Serializable data class DashPayProfile(val identityIdHex: String) + +/** Ignored-senders list (← `IgnoredContactsView.swift`). */ +@Serializable data class DashPayIgnored(val identityIdHex: String) + +/** Hidden established-contacts list (← `HiddenContactsView.swift`). */ +@Serializable data class DashPayHidden(val ownerIdentityIdHex: String) + // ── Contracts graph ──────────────────────────────────────────────────── /** Fetch-a-contract screen (← `LocalDataContractsView.swift`). */ @@ -345,6 +370,6 @@ enum class RootTab( SYNC(SyncHome, SyncHome::class, "rootTab.sync"), WALLETS(WalletsHome, WalletsHome::class, "rootTab.wallets"), IDENTITIES(IdentitiesHome, IdentitiesHome::class, "rootTab.identities"), - CONTRACTS(ContractsHome, ContractsHome::class, "rootTab.contracts"), + DASHPAY(DashPayHome, DashPayHome::class, "rootTab.dashpay"), SETTINGS(SettingsHome, SettingsHome::class, "rootTab.settings"), } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/MainScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/MainScreen.kt index 7340d9924a8..8a7b4213bc2 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/MainScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/MainScreen.kt @@ -5,7 +5,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.AccountBalanceWallet -import androidx.compose.material.icons.filled.Description +import androidx.compose.material.icons.filled.Group import androidx.compose.material.icons.filled.Person import androidx.compose.material.icons.filled.Settings import androidx.compose.material.icons.filled.Sync @@ -95,7 +95,7 @@ private val RootTab.icon: ImageVector RootTab.SYNC -> Icons.Default.Sync RootTab.WALLETS -> Icons.Default.AccountBalanceWallet RootTab.IDENTITIES -> Icons.Default.Person - RootTab.CONTRACTS -> Icons.Default.Description + RootTab.DASHPAY -> Icons.Default.Group RootTab.SETTINGS -> Icons.Default.Settings } @@ -104,6 +104,6 @@ private val RootTab.label: String RootTab.SYNC -> "Sync" RootTab.WALLETS -> "Wallets" RootTab.IDENTITIES -> "Identities" - RootTab.CONTRACTS -> "Contracts" + RootTab.DASHPAY -> "DashPay" RootTab.SETTINGS -> "Settings" } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt new file mode 100644 index 00000000000..7a92748c67e --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -0,0 +1,529 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Check +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.QrCodeScanner +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.navigation.QrScanner +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.SubmitButton +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex + +private enum class AddMode { DPNS, IDENTITY_ID } + +private sealed interface SearchState { + data object Idle : SearchState + data object Searching : SearchState + data object NotFound : SearchState + data class Found(val results: List) : SearchState +} + +/** + * Add-a-contact form — port of `AddContactView.swift`. Two entry modes: + * DPNS username (300 ms-debounced live prefix search) and a pasted base58 + * identity id (inline 32-byte validation). A resolved target renders a + * preview card that gates Send; sending a request the target already sent + * *you* surfaces a collision dialog (Accept vs Continue anyway). A QR entry + * scans a DIP-15 auto-accept code and sends the request it describes. + * + * Deviation from Swift: the DPNS hint recorded on success is written to the + * device-local meta store directly here (Swift funnels it through the tab's + * `onSent`); the cross-screen optimistic-send overlay is not reproduced. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AddContactScreen(identityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = container.dashPayContactMetaStore + + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { + walletId?.let { manager?.wallet(forWalletId = it) } + } + + var mode by remember { mutableStateOf(AddMode.DPNS) } + var searchText by remember { mutableStateOf("") } + var searchState by remember { mutableStateOf(SearchState.Idle) } + var selectedResult by remember { mutableStateOf(null) } + var idText by remember { mutableStateOf("") } + var accountLabel by remember { mutableStateOf("") } + var isSending by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var collisionRecipient by remember { mutableStateOf(null) } + var previewProfile by remember { mutableStateOf(null) } + + val parsedIdentityId = remember(idText) { Base58.decodeIdentifier(idText) } + val resolvedRecipient: ByteArray? = when (mode) { + AddMode.DPNS -> selectedResult?.identityId + AddMode.IDENTITY_ID -> parsedIdentityId + } + val recipientIsSelf = resolvedRecipient?.contentEquals(idBytes) == true + val canSend = resolvedRecipient != null && !recipientIsSelf && !isSending + + // Scan-to-send: consume the QR string handed back by the scanner screen + // (the house pattern — observe the saved-state flow, then null it out). + val savedStateHandle = navController.currentBackStackEntry?.savedStateHandle + LaunchedEffect(savedStateHandle, wallet, manager) { + savedStateHandle + ?.getStateFlow(QrScanner.RESULT_KEY, null) + ?.collect { raw -> + if (raw == null) return@collect + savedStateHandle[QrScanner.RESULT_KEY] = null + val w = wallet ?: return@collect + val m = manager ?: return@collect + isSending = true + errorMessage = null + try { + w.dashpay.sendContactRequestFromQr( + senderIdentityId = idBytes, + uri = raw.trim(), + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ).close() + kickDashPaySync(scope, m) + navController.popBackStack() + } catch (e: Exception) { + errorMessage = "QR request failed: ${e.message ?: "unknown error"}" + } finally { + isSending = false + } + } + } + + // Debounced (~300 ms) DPNS prefix search. Re-keying on searchText cancels + // the prior lookup for free; min 2 chars. + LaunchedEffect(searchText) { + selectedResult = null + val trimmed = searchText.trim() + if (trimmed.length < 2) { + searchState = SearchState.Idle + return@LaunchedEffect + } + delay(300) + val w = wallet + if (w == null) { + searchState = SearchState.Idle + errorMessage = "No wallet available for this identity" + return@LaunchedEffect + } + searchState = SearchState.Searching + try { + val results = parseDpnsSearchResults(w.dashpay.searchDpnsNames(trimmed, 10)) + searchState = if (results.isEmpty()) SearchState.NotFound else SearchState.Found(results) + } catch (e: Exception) { + searchState = SearchState.Idle + errorMessage = "Search failed: ${e.message ?: "unknown error"}" + } + } + + // Cache-only profile for the preview card (local read; most unknown + // identities won't have one). + val recipientHex = resolvedRecipient?.toHex() + LaunchedEffect(recipientHex) { + val recipient = resolvedRecipient + val w = wallet + previewProfile = if (recipient != null && w != null) { + parseDashPayProfile( + w.dashpay.getContactProfile(idBytes, recipient) + ?: w.dashpay.getProfile(recipient), + ) + } else { + null + } + } + + fun send(recipient: ByteArray) { + val w = wallet ?: return + val m = manager ?: return + isSending = true + errorMessage = null + scope.launch { + try { + val label = accountLabel.trim().ifEmpty { null } + w.dashpay.sendContactRequest( + senderIdentityId = idBytes, + recipientIdentityId = recipient, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + accountLabel = label, + ).close() + if (mode == AddMode.DPNS) { + selectedResult?.label?.let { + metaStore.setDpnsHint(it, network, idBytes, recipient) + } + } + kickDashPaySync(scope, m) + navController.popBackStack() + } catch (e: Exception) { + errorMessage = e.message ?: "Send failed" + } finally { + isSending = false + } + } + } + + fun acceptIncoming(recipient: ByteArray) { + val w = wallet ?: return + val m = manager ?: return + isSending = true + errorMessage = null + scope.launch { + try { + val ok = w.dashpay.acceptIncomingRequest( + ourIdentityId = idBytes, + senderId = recipient, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ) + if (ok) { + kickDashPaySync(scope, m) + navController.popBackStack() + } else { + errorMessage = "Their request isn't in local state — pull to refresh and " + + "accept it from Requests." + } + } catch (e: Exception) { + errorMessage = "Accept failed: ${e.message ?: "unknown error"}" + } finally { + isSending = false + } + } + } + + fun attemptSend() { + val recipient = resolvedRecipient ?: return + errorMessage = null + scope.launch { + val pendingIncoming = runCatching { + container.database.dashpayDao().getContactRequestsByOwner(idBytes) + .filter { it.contactIdentityId.contentEquals(recipient) } + .let { pair -> pair.any { !it.isOutgoing } && pair.none { it.isOutgoing } } + }.getOrDefault(false) + if (pendingIncoming) { + collisionRecipient = recipient + } else { + send(recipient) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Add Contact") }, + navigationIcon = { + TextButton( + onClick = { navController.popBackStack() }, + enabled = !isSending, + modifier = Modifier.testTag("dashpay.addContact.cancel"), + ) { Text("Cancel") } + }, + actions = { + IconButton( + onClick = { navController.navigate(QrScanner) }, + enabled = !isSending, + modifier = Modifier.testTag("dashpay.addViaQR"), + ) { + Icon(Icons.Default.QrCodeScanner, contentDescription = "Scan QR") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + org.dashfoundation.example.ui.components.AccessiblePicker( + label = "Search by", + options = AddMode.entries, + selected = mode, + optionLabel = { if (it == AddMode.DPNS) "Username (DPNS)" else "Identity ID" }, + testTag = "dashpay.addContact.mode", + ) { mode = it } + + when (mode) { + AddMode.DPNS -> DpnsSection( + searchText = searchText, + onSearchTextChange = { searchText = it }, + onClear = { searchText = "" }, + state = searchState, + selectedResult = selectedResult, + onSelect = { selectedResult = it; errorMessage = null }, + ) + AddMode.IDENTITY_ID -> IdSection( + idText = idText, + onIdTextChange = { idText = it }, + isInvalid = idText.trim().isNotEmpty() && parsedIdentityId == null, + ) + } + + val recipient = resolvedRecipient + if (recipient != null) { + PreviewSection( + recipient = recipient, + profile = previewProfile, + dpnsLabel = selectedResult?.label, + isSelf = recipientIsSelf, + ) + FormSection(title = "Account label (optional)") { + OutlinedTextField( + value = accountLabel, + onValueChange = { accountLabel = it }, + modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.accountLabel"), + placeholder = { Text("e.g. Main wallet") }, + singleLine = true, + ) + } + SubmitButton( + text = "Send Request", + isLoading = isSending, + enabled = canSend, + modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.send"), + ) { attemptSend() } + } + + if (errorMessage != null) { + Text( + errorMessage!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } + } + + val collision = collisionRecipient + if (collision != null) { + AlertDialog( + onDismissRequest = { collisionRecipient = null }, + title = { Text("Request already received") }, + text = { Text("This person already sent you a request — accept it instead?") }, + confirmButton = { + TextButton(onClick = { + collisionRecipient = null + acceptIncoming(collision) + }) { Text("Accept") } + }, + dismissButton = { + TextButton(onClick = { + collisionRecipient = null + send(collision) + }) { Text("Continue anyway") } + }, + ) + } +} + +@Composable +private fun DpnsSection( + searchText: String, + onSearchTextChange: (String) -> Unit, + onClear: () -> Unit, + state: SearchState, + selectedResult: DpnsSearchResult?, + onSelect: (DpnsSearchResult) -> Unit, +) { + FormSection(title = "Username") { + OutlinedTextField( + value = searchText, + onValueChange = onSearchTextChange, + modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.input"), + placeholder = { Text("Search usernames") }, + singleLine = true, + trailingIcon = { + if (searchText.isNotEmpty()) { + IconButton(onClick = onClear, modifier = Modifier.testTag("dashpay.addContact.clear")) { + Icon(Icons.Default.Clear, contentDescription = "Clear") + } + } + }, + ) + when (state) { + SearchState.Idle -> { + if (searchText.trim().length < 2) { + HintText("Type at least 2 characters to search.") + } + } + SearchState.Searching -> { + Row( + modifier = Modifier.padding(vertical = 8.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + HintText("Searching…") + } + } + SearchState.NotFound -> { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + HintText("No usernames match \"${searchText.trim()}\".") + TextButton( + onClick = onClear, + modifier = Modifier.testTag("dashpay.addContact.retry"), + ) { Text("Clear and try again") } + } + } + is SearchState.Found -> { + state.results.forEach { result -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { onSelect(result) } + .testTag("dashpay.addContact.result.${result.label}") + .padding(vertical = 6.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(avatarUrl = null, displayName = result.label, size = 32.dp) + Column(Modifier.weight(1f)) { + Text(result.label, style = MaterialTheme.typography.bodyLarge) + Text( + Base58.encode(result.identityId).take(16) + "…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + if (selectedResult == result) { + Icon( + Icons.Default.Check, + contentDescription = "Selected", + tint = MaterialTheme.colorScheme.primary, + ) + } + } + } + } + } + } +} + +@Composable +private fun IdSection(idText: String, onIdTextChange: (String) -> Unit, isInvalid: Boolean) { + FormSection(title = "Identity ID") { + OutlinedTextField( + value = idText, + onValueChange = onIdTextChange, + modifier = Modifier.fillMaxWidth().testTag("dashpay.addContact.idInput"), + placeholder = { Text("Paste identity ID (base58)") }, + singleLine = true, + ) + if (isInvalid) { + Text( + "Not a valid identity id (expected base58)", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun PreviewSection( + recipient: ByteArray, + profile: DashPayProfile?, + dpnsLabel: String?, + isSelf: Boolean, +) { + val name = dashPayContactDisplayName( + contactId = recipient, + alias = null, + profileDisplayName = profile?.displayName, + dpnsLabel = dpnsLabel, + ) + FormSection(title = "Send to") { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(profile?.avatarUrl, name) + Column(Modifier.weight(1f)) { + Text(name, style = MaterialTheme.typography.titleMedium) + Text( + Base58.encode(recipient).take(20) + "…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + val message = profile?.publicMessage?.trim() + if (!message.isNullOrEmpty()) { + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + ) + } + } + } + if (isSelf) { + Text( + "That's this identity — pick someone else.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun HintText(text: String) { + Text( + text, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt new file mode 100644 index 00000000000..9d51aadd27a --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt @@ -0,0 +1,467 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.automirrored.filled.Send +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.rememberModalBottomSheetState +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.persistence.entities.DashpayPaymentEntity +import org.dashfoundation.dashsdk.tokens.ContactInfoPublishOutcome +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.LabeledContent +import org.dashfoundation.example.ui.theme.appStatusColors +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.formatDuffs +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex + +/** + * One contact's detail — port of `ContactDetailView.swift`: profile header, + * Send Dash (via [SendDashPayPaymentSheet]), Room-driven payment history + * (refreshed through the durable `refreshDashPayPayments` path), and the + * device-synced alias / note / hide controls backed by + * `dashpay.setContactInfo` — surfacing the DIP-15 deferred / watch-only + * publish outcomes as notices. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactDetailScreen( + identityIdHex: String, + contactIdHex: String, + navController: NavHostController, +) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + val contactBytes = remember(contactIdHex) { contactIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = container.dashPayContactMetaStore + val metaVersion by metaStore.version.collectAsStateWithLifecycle() + + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + + val allRows by remember(idBytes) { + container.database.dashpayDao().observeContactRequests(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val pairRows = remember(allRows, contactBytes) { + allRows.filter { it.contactIdentityId.contentEquals(contactBytes) } + } + val contactProfile by remember(idBytes, contactBytes) { + container.database.dashpayDao().observeContactProfile(idBytes, contactBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val payments by remember(idBytes, contactBytes) { + container.database.dashpayDao().observePayments(idBytes, contactBytes) + }.collectAsStateWithLifecycle(emptyList()) + val sortedPayments = remember(payments) { payments.sortedByDescending { it.createdAt.time } } + + val channelBroken = pairRows.any { it.paymentChannelBroken } + val localAlias = pairRows.firstNotNullOfOrNull { it.contactAlias } + val localNote = pairRows.firstNotNullOfOrNull { it.contactNote } + val contactAccountLabel = pairRows.firstOrNull { !it.isOutgoing }?.contactAccountLabel + val isHidden = pairRows.any { it.contactHidden } + val dpnsHint = remember(metaVersion, network) { metaStore.dpnsHint(network, idBytes, contactBytes) } + val displayName = dashPayContactDisplayName( + contactId = contactBytes, + alias = localAlias, + profileDisplayName = contactProfile?.displayName, + dpnsLabel = dpnsHint, + ) + + var showPaymentSheet by remember { mutableStateOf(false) } + var aliasEditorOpen by remember { mutableStateOf(false) } + var noteEditorOpen by remember { mutableStateOf(false) } + var isRefreshingPayments by remember { mutableStateOf(false) } + var paymentsError by remember { mutableStateOf(null) } + var isSavingContactInfo by remember { mutableStateOf(false) } + var contactInfoError by remember { mutableStateOf(null) } + var publishNotice by remember { mutableStateOf(null) } + + fun refreshPayments() { + val m = manager ?: return + val wid = walletId ?: run { paymentsError = "Identity has no wallet association"; return } + if (isRefreshingPayments) return + isRefreshingPayments = true + paymentsError = null + scope.launch { + try { + m.refreshDashPayPayments(wid, idBytes) + } catch (e: Exception) { + paymentsError = "Payment refresh failed: ${e.message ?: "unknown error"}" + } finally { + isRefreshingPayments = false + } + } + } + + fun saveContactInfo(alias: String?, note: String?, hidden: Boolean) { + val m = manager ?: return + val wid = walletId ?: run { contactInfoError = "No wallet available for this identity"; return } + val wallet = m.wallet(forWalletId = wid) ?: run { + contactInfoError = "No wallet available for this identity" + return + } + isSavingContactInfo = true + contactInfoError = null + publishNotice = null + scope.launch { + try { + val outcome = wallet.dashpay.setContactInfo( + identityId = idBytes, + contactId = contactBytes, + alias = alias?.ifBlank { null }, + note = note?.ifBlank { null }, + displayHidden = hidden, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ) + publishNotice = when (outcome) { + ContactInfoPublishOutcome.PUBLISHED -> null + ContactInfoPublishOutcome.DEFERRED_UNTIL_TWO_CONTACTS -> + "Saved on this device. It will sync to your other devices once this " + + "identity has two or more contacts." + ContactInfoPublishOutcome.SKIPPED_WATCH_ONLY -> + "Saved on this device only — this watch-only identity can't publish to Platform." + } + } catch (e: Exception) { + contactInfoError = "Save failed: ${e.message ?: "unknown error"}" + } finally { + isSavingContactInfo = false + } + } + } + + LaunchedEffect(Unit) { refreshPayments() } + val syncingFlow = remember(manager) { manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false) } + val isSyncing by syncingFlow.collectAsStateWithLifecycle(false) + LaunchedEffect(isSyncing) { if (!isSyncing) refreshPayments() } + + var paymentSending by remember { mutableStateOf(false) } + + Scaffold( + topBar = { + TopAppBar( + title = { Text(displayName) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + // Header + FormSection { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(contactProfile?.avatarUrl, displayName, size = 56.dp) + Column(Modifier.weight(1f)) { + Text(displayName, style = MaterialTheme.typography.titleLarge) + dpnsHint?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + Text( + Base58.encode(contactBytes).take(20) + "…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + contactProfile?.publicMessage?.trim()?.takeIf { it.isNotEmpty() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 2) + } + } + } + localNote?.let { LabeledContent("Note", it) } + contactAccountLabel?.let { LabeledContent("Their account", it) } + } + + // Send + FormSection { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = !channelBroken) { showPaymentSheet = true } + .testTag("dashpay.detail.sendDash"), + leadingContent = { Icon(Icons.AutoMirrored.Filled.Send, contentDescription = null) }, + headlineContent = { + Text( + "Send Dash", + color = if (channelBroken) MaterialTheme.colorScheme.onSurfaceVariant else MaterialTheme.colorScheme.onSurface, + ) + }, + ) + if (channelBroken) { + Text( + "Payment channel broken — ask the contact to send a new request", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + } + } + + // Payments + FormSection(title = "Payments (${sortedPayments.size})") { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + IconButton( + onClick = { refreshPayments() }, + enabled = !isRefreshingPayments, + modifier = Modifier.testTag("dashpay.detail.refreshPayments"), + ) { + if (isRefreshingPayments) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + Icon(Icons.Default.Refresh, contentDescription = "Refresh payments") + } + } + } + if (sortedPayments.isEmpty()) { + Text( + if (isRefreshingPayments) "Loading payments…" else "No payments yet", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + sortedPayments.forEach { PaymentHistoryRow(it) } + } + paymentsError?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } + + // Local settings + FormSection(title = "Contact settings") { + ListItem( + modifier = Modifier.fillMaxWidth().clickable { aliasEditorOpen = true }.testTag("dashpay.detail.aliasEdit"), + headlineContent = { Text("Alias") }, + trailingContent = { Text(localAlias ?: "None", color = MaterialTheme.colorScheme.onSurfaceVariant) }, + ) + ListItem( + modifier = Modifier.fillMaxWidth().clickable { noteEditorOpen = true }.testTag("dashpay.detail.noteEdit"), + headlineContent = { Text("Note") }, + trailingContent = { Text(if (localNote == null) "None" else "Edit", color = MaterialTheme.colorScheme.onSurfaceVariant) }, + ) + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 6.dp), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Text("Hide contact") + Switch( + checked = isHidden, + onCheckedChange = { saveContactInfo(localAlias, localNote, it) }, + enabled = !isSavingContactInfo, + modifier = Modifier.testTag("dashpay.detail.hideToggle"), + ) + } + if (isSavingContactInfo) { + Text("Saving…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + contactInfoError?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + publishNotice?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.tertiary) + } + Text( + "Alias, note and hide are encrypted and synced to your other devices via " + + "Platform once this identity has two or more contacts.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + } + + if (showPaymentSheet && manager != null && walletId != null) { + // Block interactive dismissal (swipe / scrim / back) while a send is + // in flight, so the sheet can't be torn down mid-broadcast (the send + // itself is also NonCancellable — defense in depth). + val sheetState = rememberModalBottomSheetState(confirmValueChange = { !paymentSending }) + ModalBottomSheet( + onDismissRequest = { if (!paymentSending) showPaymentSheet = false }, + sheetState = sheetState, + ) { + SendDashPayPaymentSheet( + manager = manager!!, + walletId = walletId!!, + senderIdentityId = idBytes, + contactId = contactBytes, + contactDisplayName = displayName, + contactDpnsName = dpnsHint, + onSendingChange = { paymentSending = it }, + onSent = { refreshPayments() }, + onClose = { showPaymentSheet = false }, + ) + } + } + + if (aliasEditorOpen) { + LocalFieldEditor( + title = "Alias", + prompt = "e.g. Mom", + initialValue = localAlias ?: "", + identifierPrefix = "dashpay.detail.alias", + onDismiss = { aliasEditorOpen = false }, + onSave = { value -> + aliasEditorOpen = false + saveContactInfo(value, localNote, isHidden) + }, + ) + } + if (noteEditorOpen) { + LocalFieldEditor( + title = "Note", + prompt = "Anything to remember about this contact", + initialValue = localNote ?: "", + identifierPrefix = "dashpay.detail.note", + onDismiss = { noteEditorOpen = false }, + onSave = { value -> + noteEditorOpen = false + saveContactInfo(localAlias, value, isHidden) + }, + ) + } +} + +@Composable +private fun PaymentHistoryRow(payment: DashpayPaymentEntity) { + val sent = payment.directionRaw == 0 + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 2.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Column(Modifier.weight(1f)) { + Text( + if (sent) "Sent" else "Received", + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.Medium, + ) + Text( + payment.txid.take(16) + "…", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + payment.memo?.takeIf { it.isNotEmpty() }?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1) + } + } + Column(horizontalAlignment = Alignment.End) { + Text(formatDuffs(payment.amountDuffs), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold) + Text( + statusLabel(payment.statusRaw), + style = MaterialTheme.typography.bodySmall, + color = statusColor(payment.statusRaw), + ) + } + } +} + +@Composable +private fun statusColor(statusRaw: Int) = when (statusRaw) { + 1 -> appStatusColors.success + 2 -> MaterialTheme.colorScheme.error + else -> MaterialTheme.colorScheme.tertiary +} + +private fun statusLabel(statusRaw: Int) = when (statusRaw) { + 1 -> "Confirmed" + 2 -> "Failed" + else -> "Pending" +} + +@Composable +private fun LocalFieldEditor( + title: String, + prompt: String, + initialValue: String, + identifierPrefix: String, + onDismiss: () -> Unit, + onSave: (String?) -> Unit, +) { + var value by remember { mutableStateOf(initialValue) } + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(title) }, + text = { + OutlinedTextField( + value = value, + onValueChange = { value = it }, + modifier = Modifier.fillMaxWidth().testTag("$identifierPrefix.field"), + placeholder = { Text(prompt) }, + singleLine = true, + ) + }, + confirmButton = { + TextButton( + onClick = { onSave(value.trim().ifEmpty { null }) }, + modifier = Modifier.testTag("$identifierPrefix.save"), + ) { Text("Save") } + }, + dismissButton = { + TextButton(onClick = onDismiss, modifier = Modifier.testTag("$identifierPrefix.cancel")) { + Text("Cancel") + } + }, + ) +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt new file mode 100644 index 00000000000..b6057c802ed --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -0,0 +1,352 @@ +package org.dashfoundation.example.ui.dashpay + +import android.text.format.DateUtils +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.Button +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.ui.components.SectionHeader +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex + +/** + * Incoming + outgoing contact requests — port of `ContactRequestsView.swift`. + * Incoming rows carry Accept / Ignore with per-row in-flight state + inline + * error; the Outgoing section shows pending sent requests. Rows are derived + * from the Room contact-request rows: a `(owner, contact)` pair with only an + * incoming row is a pending incoming request, only an outgoing row a pending + * sent one, and both directions an established contact (shown in Contacts). + * + * Deviation from Swift: the cross-screen optimistic *sent* overlay is dropped + * (AddContact is a separate route here, not a child sharing a `@Binding`); a + * sent request appears once the post-send sync persists its outgoing row. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactRequestsScreen(identityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = container.dashPayContactMetaStore + val metaVersion by metaStore.version.collectAsStateWithLifecycle() + + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { + walletId?.let { manager?.wallet(forWalletId = it) } + } + + val rows by remember(idBytes) { + container.database.dashpayDao().observeContactRequests(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val contactProfiles by remember(idBytes) { + container.database.dashpayDao().observeContactProfiles(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val profilesByHex = remember(contactProfiles) { + contactProfiles.associateBy { it.contactIdentityId.toHex() } + } + + var inFlightIds by remember { mutableStateOf(emptySet()) } + var removedOverlayIds by remember { mutableStateOf(emptySet()) } + var rowErrors by remember { mutableStateOf(emptyMap()) } + var isRefreshing by remember { mutableStateOf(false) } + + // Prune the optimistic-removal overlay against the backing query: keep a + // hex only while its pair is STILL incoming-only, so an entry is dropped + // exactly when Room reflects the action's own change (accept promotes the + // pair to established → it gains an outgoing row; ignore deletes the row → + // the group disappears). This is scoped to the row change itself, so an + // unrelated sweep completing can no longer flash the row back. + LaunchedEffect(rows) { + val byContact = rows.groupBy { it.contactIdentityId.toHex() } + removedOverlayIds = removedOverlayIds.filter { hex -> + val group = byContact[hex] ?: return@filter false + group.any { !it.isOutgoing } && group.none { it.isOutgoing } + }.toSet() + } + + fun displayNameFor(contactId: ByteArray): String = dashPayContactDisplayName( + contactId = contactId, + alias = metaStore.alias(network, idBytes, contactId), + profileDisplayName = profilesByHex[contactId.toHex()]?.displayName, + dpnsLabel = metaStore.dpnsHint(network, idBytes, contactId), + ) + + val incomingPending = remember(rows, removedOverlayIds, profilesByHex, metaVersion, network) { + rows.groupBy { it.contactIdentityId.toHex() } + .mapNotNull { (hex, group) -> + if (removedOverlayIds.contains(hex)) return@mapNotNull null + if (group.any { it.isOutgoing }) return@mapNotNull null + val incoming = group.firstOrNull { !it.isOutgoing } ?: return@mapNotNull null + RequestRowItem( + contactId = incoming.contactIdentityId, + displayName = displayNameFor(incoming.contactIdentityId), + // Privacy: never load an unsolicited sender's avatar before + // the user accepts (an image GET leaks the recipient's IP). + avatarUrl = null, + createdAtMillis = incoming.createdAtMillis, + ) + } + .sortedByDescending { it.createdAtMillis } + } + + val outgoingPending = remember(rows, profilesByHex, metaVersion, network) { + rows.groupBy { it.contactIdentityId.toHex() } + .mapNotNull { (hex, group) -> + if (group.any { !it.isOutgoing }) return@mapNotNull null + val outgoing = group.firstOrNull { it.isOutgoing } ?: return@mapNotNull null + RequestRowItem( + contactId = outgoing.contactIdentityId, + displayName = displayNameFor(outgoing.contactIdentityId), + avatarUrl = profilesByHex[hex]?.avatarUrl, + createdAtMillis = outgoing.createdAtMillis, + ) + } + .sortedByDescending { it.createdAtMillis } + } + + fun accept(contactId: ByteArray) { + val w = wallet ?: return + val m = manager ?: return + val hex = contactId.toHex() + rowErrors = rowErrors - hex + inFlightIds = inFlightIds + hex + scope.launch { + try { + val ok = w.dashpay.acceptIncomingRequest( + ourIdentityId = idBytes, + senderId = contactId, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ) + if (ok) { + removedOverlayIds = removedOverlayIds + hex + kickDashPaySync(scope, m) + } else { + rowErrors = rowErrors + (hex to "Request not in local state — pull to refresh") + } + } catch (e: Exception) { + rowErrors = rowErrors + (hex to "Accept failed: ${e.message ?: "unknown error"}") + } finally { + inFlightIds = inFlightIds - hex + } + } + } + + fun ignore(contactId: ByteArray) { + val w = wallet ?: return + val hex = contactId.toHex() + rowErrors = rowErrors - hex + inFlightIds = inFlightIds + hex + scope.launch { + try { + w.dashpay.ignoreContactSender(ourIdentityId = idBytes, contactIdentityId = contactId) + removedOverlayIds = removedOverlayIds + hex + } catch (e: Exception) { + rowErrors = rowErrors + (hex to "Ignore failed: ${e.message ?: "unknown error"}") + } finally { + inFlightIds = inFlightIds - hex + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Requests") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + scope.launch { + isRefreshing = true + manager?.let { attachOrStartSync(it) } + isRefreshing = false + } + }, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (incomingPending.isEmpty() && outgoingPending.isEmpty()) { + item { + DashPayEmptyRow( + title = "No pending requests", + message = "Incoming contact requests and your pending sent requests " + + "show up here.", + ) + } + } else { + if (incomingPending.isNotEmpty()) { + item { SectionHeader("Incoming (${incomingPending.size})") } + items(incomingPending, key = { "in:${it.contactId.toHex()}" }) { row -> + val hex = row.contactId.toHex() + IncomingRequestRow( + item = row, + isInFlight = inFlightIds.contains(hex), + errorMessage = rowErrors[hex], + onAccept = { accept(row.contactId) }, + onIgnore = { ignore(row.contactId) }, + ) + } + } + if (outgoingPending.isNotEmpty()) { + item { SectionHeader("Outgoing (${outgoingPending.size})") } + items(outgoingPending, key = { "out:${it.contactId.toHex()}" }) { row -> + OutgoingRequestRow(row) + } + } + } + } + } + } +} + +/** UI model for one request row (incoming or outgoing). */ +private data class RequestRowItem( + val contactId: ByteArray, + val displayName: String, + val avatarUrl: String?, + val createdAtMillis: Long, +) + +@Composable +private fun IncomingRequestRow( + item: RequestRowItem, + isInFlight: Boolean, + errorMessage: String?, + onAccept: () -> Unit, + onIgnore: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(item.avatarUrl, item.displayName) + Column(Modifier.weight(1f)) { + Text(item.displayName, style = MaterialTheme.typography.titleMedium) + Text( + relativeTimestamp(item.createdAtMillis), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (isInFlight) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.Center) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } + } else { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button(onClick = onAccept, modifier = Modifier.testTag("dashpay.request.accept")) { + Text("Accept") + } + OutlinedButton( + onClick = onIgnore, + modifier = Modifier.testTag("dashpay.request.ignore"), + ) { + Text("Ignore") + } + } + } + if (errorMessage != null) { + Text( + errorMessage, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + } +} + +@Composable +private fun OutgoingRequestRow(item: RequestRowItem) { + Row( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(item.avatarUrl, item.displayName) + Column(Modifier.weight(1f)) { + Text(item.displayName, style = MaterialTheme.typography.titleMedium) + Text( + relativeTimestamp(item.createdAtMillis), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + Text( + "Pending", + style = MaterialTheme.typography.labelMedium, + fontWeight = FontWeight.Medium, + color = MaterialTheme.colorScheme.tertiary, + ) + } +} + +/** "3 min. ago"-style relative time from Unix millis; "—" for the zero sentinel. */ +private fun relativeTimestamp(millis: Long): String { + if (millis <= 0) return "—" + return DateUtils.getRelativeTimeSpanString( + millis, + System.currentTimeMillis(), + DateUtils.MINUTE_IN_MILLIS, + ).toString() +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt new file mode 100644 index 00000000000..3cc84a9a4f5 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt @@ -0,0 +1,289 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.Clear +import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.navigation.DashPayContactDetail +import org.dashfoundation.example.navigation.DashPayHidden +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex + +/** + * Established-contacts list — port of `ContactsView.swift`. A contact is + * *established* when both direction rows exist for the same + * `(owner, contact)` pair (the local projection of the Rust `established` + * map); hidden contacts stay established but leave this list. Rows read + * cached names/avatars from Room + * ([org.dashfoundation.dashsdk.persistence.dao.DashpayDao.observeContactProfiles]) + * joined with the device-local alias/DPNS-hint meta store, searchable, with + * a "Hidden contacts" recovery link and pull-to-refresh. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ContactsScreen(identityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = container.dashPayContactMetaStore + val metaVersion by metaStore.version.collectAsStateWithLifecycle() + + val rows by remember(idBytes) { + container.database.dashpayDao().observeContactRequests(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val contactProfiles by remember(idBytes) { + container.database.dashpayDao().observeContactProfiles(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + + var searchText by remember { mutableStateOf("") } + var isRefreshing by remember { mutableStateOf(false) } + + val profilesByHex = remember(contactProfiles) { + contactProfiles.associateBy { it.contactIdentityId.toHex() } + } + + val established = remember(rows, profilesByHex, metaVersion, network) { + rows.groupBy { it.contactIdentityId.toHex() } + .mapNotNull { (hex, group) -> + val hasOutgoing = group.any { it.isOutgoing } + val hasIncoming = group.any { !it.isOutgoing } + if (!hasOutgoing || !hasIncoming) return@mapNotNull null + if (group.any { it.contactHidden }) return@mapNotNull null + val contactId = group.first().contactIdentityId + val profile = profilesByHex[hex] + val dpnsHint = metaStore.dpnsHint(network, idBytes, contactId) + EstablishedContact( + contactId = contactId, + displayName = dashPayContactDisplayName( + contactId = contactId, + alias = group.firstNotNullOfOrNull { it.contactAlias }, + profileDisplayName = profile?.displayName, + dpnsLabel = dpnsHint, + ), + avatarUrl = profile?.avatarUrl, + dpnsName = dpnsHint, + paymentChannelBroken = group.any { it.paymentChannelBroken }, + ) + } + .sortedBy { it.displayName.lowercase() } + } + + val hasHiddenContacts = remember(rows) { + rows.groupBy { it.contactIdentityId.toHex() }.any { (_, group) -> + group.any { it.isOutgoing } && group.any { !it.isOutgoing } && + group.any { it.contactHidden } + } + } + + val filtered = remember(established, searchText) { + val trimmed = searchText.trim() + if (trimmed.isEmpty()) { + established + } else { + established.filter { contact -> + contact.displayName.contains(trimmed, ignoreCase = true) || + contact.dpnsName?.contains(trimmed, ignoreCase = true) == true || + contact.contactId.toHex().startsWith(trimmed.lowercase()) + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Contacts") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { + scope.launch { + isRefreshing = true + manager?.let { attachOrStartSync(it) } + isRefreshing = false + } + }, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (filtered.isEmpty() && searchText.isEmpty() && !hasHiddenContacts) { + item { + DashPayEmptyRow( + title = "No contacts yet", + message = "Add your first contact to send Dash by username.", + ) + } + } else { + item { + SearchField( + value = searchText, + onValueChange = { searchText = it }, + onClear = { searchText = "" }, + ) + } + items(filtered, key = { it.contactId.toHex() }) { contact -> + ContactRow( + contact = contact, + onClick = { + navController.navigate( + DashPayContactDetail( + identityIdHex = identityIdHex, + contactIdHex = contact.contactId.toHex(), + ), + ) + }, + ) + } + if (hasHiddenContacts) { + item { + ListItem( + headlineContent = { Text("Hidden contacts") }, + modifier = Modifier + .fillMaxWidth() + .clickable { navController.navigate(DashPayHidden(idBytes.toHex())) } + .testTag("dashpay.openHidden"), + ) + } + } + } + } + } + } +} + +/** UI model for one established contact row. */ +data class EstablishedContact( + val contactId: ByteArray, + val displayName: String, + val avatarUrl: String?, + val dpnsName: String?, + val paymentChannelBroken: Boolean, +) { + override fun equals(other: Any?): Boolean = + other is EstablishedContact && contactId.contentEquals(other.contactId) + + override fun hashCode(): Int = contactId.contentHashCode() +} + +@Composable +private fun ContactRow(contact: EstablishedContact, onClick: () -> Unit) { + ListItem( + modifier = Modifier + .fillMaxWidth() + .clickable { onClick() } + .testTag("dashpay.contact.${Base58.encode(contact.contactId)}"), + leadingContent = { DashPayAvatar(contact.avatarUrl, contact.displayName) }, + headlineContent = { Text(contact.displayName, style = MaterialTheme.typography.titleMedium) }, + supportingContent = { + Text( + contact.dpnsName ?: (contact.contactId.toHex().take(12) + "…"), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + }, + trailingContent = if (contact.paymentChannelBroken) { + { + Icon( + Icons.Default.Warning, + contentDescription = "Payment channel broken", + tint = MaterialTheme.colorScheme.error, + ) + } + } else { + null + }, + ) +} + +/** Shared inline search row (← Swift `searchField`). */ +@Composable +internal fun SearchField(value: String, onValueChange: (String) -> Unit, onClear: () -> Unit) { + OutlinedTextField( + value = value, + onValueChange = onValueChange, + modifier = Modifier.fillMaxWidth().testTag("dashpay.search"), + placeholder = { Text("Search contacts") }, + singleLine = true, + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { + if (value.isNotEmpty()) { + IconButton(onClick = onClear, modifier = Modifier.testTag("dashpay.search.clear")) { + Icon(Icons.Default.Clear, contentDescription = "Clear search") + } + } + }, + ) +} + +/** + * Inline empty state — the shared "list empty" row (← Swift + * `DashPayListEmptyRow`), kept inside the scrollable so pull-to-refresh + * still works on an empty list. + */ +@Composable +internal fun DashPayEmptyRow(title: String, message: String) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt new file mode 100644 index 00000000000..45400b36214 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt @@ -0,0 +1,183 @@ +package org.dashfoundation.example.ui.dashpay + +import android.content.Context +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.SubcomposeAsyncImage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import org.dashfoundation.dashsdk.Network +import org.dashfoundation.example.util.toHex + +/** + * Device-local, per-contact metadata for the DashPay tab — port of + * `DashPayContactMeta.swift`'s `DashPayContactMetaStore`: alias, note, + * hidden flag, and a DPNS-label hint captured at add time. + * + * These are scoped to "This device only" — a later milestone replaces + * this store with `contactInfo` documents synced via Platform. Until + * then [SharedPreferences] is the honest backing (no sync semantics), the + * counterpart of iOS `UserDefaults`. + * + * Keys are scoped by `(network, owner identity, contact identity)` so two + * owner identities (or two networks) never share a contact's alias. The + * [version] counter is bumped on every write so Compose readers that + * compute reads through this store recompose after a write — plain + * SharedPreferences reads don't participate in Compose invalidation. + */ +class DashPayContactMetaStore(context: Context) { + + private val prefs = context.getSharedPreferences("dashpay_contact_meta", Context.MODE_PRIVATE) + + /** Bumped on every write so observing composables recompute reads. */ + private val _version = MutableStateFlow(0) + val version: StateFlow = _version.asStateFlow() + + // ── Alias (local display-name override) ────────────────────────────── + + fun alias(network: Network, owner: ByteArray, contact: ByteArray): String? = + nonEmpty(prefs.getString(key("alias", network, owner, contact), null)) + + fun setAlias(alias: String?, network: Network, owner: ByteArray, contact: ByteArray) { + write(nonEmpty(alias), key("alias", network, owner, contact)) + } + + // ── Note ───────────────────────────────────────────────────────────── + + fun note(network: Network, owner: ByteArray, contact: ByteArray): String? = + nonEmpty(prefs.getString(key("note", network, owner, contact), null)) + + fun setNote(note: String?, network: Network, owner: ByteArray, contact: ByteArray) { + write(nonEmpty(note), key("note", network, owner, contact)) + } + + // ── Hidden ─────────────────────────────────────────────────────────── + + fun isHidden(network: Network, owner: ByteArray, contact: ByteArray): Boolean = + prefs.getBoolean(key("hidden", network, owner, contact), false) + + fun setHidden(hidden: Boolean, network: Network, owner: ByteArray, contact: ByteArray) { + prefs.edit().putBoolean(key("hidden", network, owner, contact), hidden).apply() + _version.value += 1 + } + + // ── DPNS hint ──────────────────────────────────────────────────────── + + /** + * DPNS label observed when the contact was added via username search. + * Display-precedence fallback only — contacts' DPNS labels aren't + * persisted in Room (only managed identities' are), so this hint is + * "the data available" for the contact rows. + */ + fun dpnsHint(network: Network, owner: ByteArray, contact: ByteArray): String? = + nonEmpty(prefs.getString(key("dpnsHint", network, owner, contact), null)) + + fun setDpnsHint(name: String?, network: Network, owner: ByteArray, contact: ByteArray) { + write(nonEmpty(name), key("dpnsHint", network, owner, contact)) + } + + // ── Helpers ────────────────────────────────────────────────────────── + + private fun key(field: String, network: Network, owner: ByteArray, contact: ByteArray): String = + "dashpay.meta.$field.${network.ffiValue}.${owner.toHex()}.${contact.toHex()}" + + private fun write(value: String?, key: String) { + prefs.edit().apply { + if (value != null) putString(key, value) else remove(key) + }.apply() + _version.value += 1 + } + + private fun nonEmpty(value: String?): String? = value?.trim()?.takeIf { it.isNotEmpty() } +} + +// ── Display-name precedence ────────────────────────────────────────────── + +/** + * Resolve the display precedence for a DashPay contact — port of + * `dashPayContactDisplayName` in `DashPayContactMeta.swift`: local alias → + * DashPay profile `displayName` → DPNS label → truncated hex id. Every + * input but the id is optional; blank/whitespace strings count as absent. + */ +fun dashPayContactDisplayName( + contactId: ByteArray, + alias: String?, + profileDisplayName: String?, + dpnsLabel: String?, +): String { + for (candidate in listOf(alias, profileDisplayName, dpnsLabel)) { + val trimmed = candidate?.trim() + if (!trimmed.isNullOrEmpty()) return trimmed + } + return contactId.toHex().take(12) + "…" +} + +// ── Txid display order ─────────────────────────────────────────────────── + +/** + * Hex-encode a raw 32-byte txid in canonical (reversed) display order — + * port of `txidDisplayHex` in `DashPayContactMeta.swift`. The FFI hands back + * wire/internal byte order, so a bare hex reads reversed from block + * explorers; this flip lines it up with the rest of the app's tx display. + */ +fun txidDisplayHex(txid: ByteArray): String = + txid.reversed().joinToString("") { "%02x".format(it) } + +// ── Avatar ─────────────────────────────────────────────────────────────── + +/** + * Shared avatar bubble — port of `DashPayAvatarView`: the profile's + * `avatarUrl` loaded via Coil when present, an initial-circle fallback + * otherwise. The initial comes from the resolved display name; the tint is + * fixed (the same for every contact, not name-hashed), theme-aware via + * [MaterialTheme]. + */ +@Composable +fun DashPayAvatar(avatarUrl: String?, displayName: String, size: Dp = 40.dp) { + val url = avatarUrl?.trim()?.takeIf { it.isNotEmpty() } + if (url != null) { + SubcomposeAsyncImage( + model = url, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier.size(size).clip(CircleShape), + loading = { InitialsCircle(displayName, size) }, + error = { InitialsCircle(displayName, size) }, + ) + } else { + InitialsCircle(displayName, size) + } +} + +@Composable +private fun InitialsCircle(displayName: String, size: Dp) { + Box( + modifier = Modifier + .size(size) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.primary.copy(alpha = 0.2f)), + contentAlignment = Alignment.Center, + ) { + Text( + text = displayName.take(1).uppercase(), + color = MaterialTheme.colorScheme.primary, + style = if (size > 50.dp) { + MaterialTheme.typography.titleLarge + } else { + MaterialTheme.typography.titleMedium + }, + ) + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt new file mode 100644 index 00000000000..cab86124046 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt @@ -0,0 +1,179 @@ +package org.dashfoundation.example.ui.dashpay + +import org.json.JSONArray +import org.json.JSONObject + +/** + * Plain data classes + org.json parsers for the JSON-string reads on the + * DashPay FFI surface (`Dashpay.getProfile` / `getContactProfile`, + * `searchDpnsNames`, `PlatformWalletManager.accountBalances`, and + * `Dashpay.payments`). Field shapes are documented on + * `org.dashfoundation.dashsdk.ffi.DashpayNative`; parsing happens here, + * Kotlin-side, following the SDK's JSON-string-read precedent. + * + * Parsers are total and lenient: a null/blank/unparseable input yields null + * (or an empty list), and per-row failures are skipped rather than thrown, + * so a single malformed row never blanks the whole list. + */ + +// ── Profile (getProfile / getContactProfile) ───────────────────────────── + +/** Cached DashPay profile — the public fields the requests/contacts UI renders. */ +data class DashPayProfile( + val displayName: String?, + val publicMessage: String?, + val avatarUrl: String?, +) + +/** Parse a `getProfile` / `getContactProfile` JSON object, or null. */ +fun parseDashPayProfile(json: String?): DashPayProfile? { + val obj = json?.let { runCatching { JSONObject(it) }.getOrNull() } ?: return null + return DashPayProfile( + displayName = obj.optStringOrNull("displayName"), + publicMessage = obj.optStringOrNull("publicMessage"), + avatarUrl = obj.optStringOrNull("avatarUrl"), + ) +} + +// ── DPNS search (searchDpnsNames) ──────────────────────────────────────── + +/** One DPNS prefix-search hit: the resolved label + its 32-byte identity id. */ +data class DpnsSearchResult( + /** The DPNS label (Swift `fullName`) — also the row's testTag suffix. */ + val label: String, + val identityId: ByteArray, +) { + override fun equals(other: Any?): Boolean = + other is DpnsSearchResult && label == other.label && + identityId.contentEquals(other.identityId) + + override fun hashCode(): Int = 31 * label.hashCode() + identityId.contentHashCode() +} + +/** Parse a `searchDpnsNames` JSON array of `{"label":…,"identityId":…hex}`. */ +fun parseDpnsSearchResults(json: String?): List { + val array = json?.let { runCatching { JSONArray(it) }.getOrNull() } ?: return emptyList() + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val row = array.optJSONObject(i) ?: continue + val label = row.optStringOrNull("label") ?: continue + val id = row.optStringOrNull("identityId")?.hexOrNull()?.takeIf { it.size == 32 } ?: continue + out.add(DpnsSearchResult(label, id)) + } + return out +} + +// ── Account balances (PlatformWalletManager.accountBalances) ────────────── + +/** + * One per-account balance row. The DashPay "received from contacts" total is + * the sum of `confirmed + unconfirmed` over rows with [typeTag] == 12 + * (`DashpayReceivingFunds`) whose [userIdentityId] matches the identity. + */ +data class AccountBalance( + val typeTag: Int, + val userIdentityId: ByteArray, + val friendIdentityId: ByteArray, + val confirmed: Long, + val unconfirmed: Long, +) { + override fun equals(other: Any?): Boolean = + other is AccountBalance && typeTag == other.typeTag && + userIdentityId.contentEquals(other.userIdentityId) && + friendIdentityId.contentEquals(other.friendIdentityId) && + confirmed == other.confirmed && unconfirmed == other.unconfirmed + + override fun hashCode(): Int { + var result = typeTag + result = 31 * result + userIdentityId.contentHashCode() + result = 31 * result + friendIdentityId.contentHashCode() + result = 31 * result + confirmed.hashCode() + result = 31 * result + unconfirmed.hashCode() + return result + } +} + +/** Parse a `accountBalances` JSON array, or an empty list. */ +fun parseAccountBalances(json: String?): List { + val array = json?.let { runCatching { JSONArray(it) }.getOrNull() } ?: return emptyList() + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val row = array.optJSONObject(i) ?: continue + out.add( + AccountBalance( + typeTag = row.optInt("typeTag"), + userIdentityId = row.optStringOrNull("userIdentityId")?.hexOrNull() ?: ByteArray(0), + friendIdentityId = row.optStringOrNull("friendIdentityId")?.hexOrNull() + ?: ByteArray(0), + confirmed = row.optLong("confirmed"), + unconfirmed = row.optLong("unconfirmed"), + ), + ) + } + return out +} + +// ── Payment history (Dashpay.payments) ─────────────────────────────────── + +/** One DashPay payment. [direction] 0 Sent / 1 Received; [status] 0 Pending / 1 Confirmed / 2 Failed. */ +data class DashPayPayment( + val txid: String, + val counterpartyId: ByteArray, + val amountDuffs: Long, + val direction: Int, + val status: Int, + val memo: String?, +) { + override fun equals(other: Any?): Boolean = + other is DashPayPayment && txid == other.txid && + counterpartyId.contentEquals(other.counterpartyId) && + amountDuffs == other.amountDuffs && direction == other.direction && + status == other.status && memo == other.memo + + override fun hashCode(): Int { + var result = txid.hashCode() + result = 31 * result + counterpartyId.contentHashCode() + result = 31 * result + amountDuffs.hashCode() + result = 31 * result + direction + result = 31 * result + status + result = 31 * result + (memo?.hashCode() ?: 0) + return result + } +} + +/** Parse a `payments` JSON array (rows missing txid/counterparty are skipped). */ +fun parseDashPayPayments(json: String?): List { + val array = json?.let { runCatching { JSONArray(it) }.getOrNull() } ?: return emptyList() + val out = ArrayList(array.length()) + for (i in 0 until array.length()) { + val row = array.optJSONObject(i) ?: continue + val txid = row.optStringOrNull("txid") ?: continue + val counterparty = row.optStringOrNull("counterpartyId")?.hexOrNull() + ?.takeIf { it.size == 32 } ?: continue + out.add( + DashPayPayment( + txid = txid, + counterpartyId = counterparty, + amountDuffs = row.optLong("amountDuffs"), + direction = row.optInt("direction"), + status = row.optInt("status"), + memo = row.optStringOrNull("memo"), + ), + ) + } + return out +} + +// ── Helpers ────────────────────────────────────────────────────────────── + +/** `optString` but null (not the JSON literal `"null"` / empty) when absent. */ +private fun JSONObject.optStringOrNull(key: String): String? = + if (isNull(key)) null else optString(key, "").takeIf { it.isNotEmpty() } + +/** Lower/upper-hex → bytes; null on odd length or a non-hex digit. */ +private fun String.hexOrNull(): ByteArray? { + if (length % 2 != 0) return null + return runCatching { + chunked(2).map { it.toInt(16).toByte() }.toByteArray() + }.getOrNull() +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt new file mode 100644 index 00000000000..2f81559246e --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -0,0 +1,286 @@ +package org.dashfoundation.example.ui.dashpay + +import android.graphics.Bitmap +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.LabeledContent +import org.dashfoundation.example.ui.components.SubmitButton +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.generateQrBitmap +import org.dashfoundation.example.util.hexToBytes + +/** + * Own DashPay profile — port of `DashPayProfileView.swift` plus its companion + * editor: read-only display (avatar / name / DPNS / public message / id), the + * DIP-15 auto-accept QR (via `buildAutoAcceptQr`, rendered with the ZXing + * `generateQrBitmap` helper), and an inline edit mode calling + * `createOrUpdateProfile` (doCreate when no profile exists yet). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val context = LocalContext.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + + var profile by remember { mutableStateOf(null) } + var profileExists by remember { mutableStateOf(false) } + var qrUri by remember { mutableStateOf(null) } + var qrError by remember { mutableStateOf(null) } + + // Encode the QR off the main thread (ZXing is CPU work). + val qrBitmap by produceState(initialValue = null, qrUri) { + val uri = qrUri + value = if (uri != null) withContext(Dispatchers.Default) { generateQrBitmap(uri) } else null + } + + var isEditing by remember { mutableStateOf(false) } + var displayNameField by remember { mutableStateOf("") } + var publicMessageField by remember { mutableStateOf("") } + var avatarUrlField by remember { mutableStateOf("") } + var isSaving by remember { mutableStateOf(false) } + var saveError by remember { mutableStateOf(null) } + + suspend fun loadProfile() { + val w = wallet ?: return + val raw = w.dashpay.getProfile(idBytes) + profileExists = raw != null + profile = parseDashPayProfile(raw) + } + + LaunchedEffect(wallet) { + val w = wallet ?: return@LaunchedEffect + loadProfile() + val m = manager + if (m != null && qrUri == null && qrError == null) { + val username = (identity?.mainDpnsName ?: identity?.dpnsName)?.trim().orEmpty() + try { + qrUri = w.dashpay.buildAutoAcceptQr(idBytes, username, m.mnemonicResolverHandle) + } catch (e: Exception) { + qrError = "Couldn't build the QR: ${e.message ?: "unknown error"}" + } + } + } + + val displayName = profile?.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: (identity?.mainDpnsName ?: identity?.dpnsName) + ?: (Base58.encode(idBytes).take(12) + "…") + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Your Profile") }, + navigationIcon = { + TextButton( + onClick = { navController.popBackStack() }, + modifier = Modifier.testTag("dashpay.profile.done"), + ) { Text("Done") } + }, + actions = { + TextButton( + onClick = { + if (!isEditing) { + displayNameField = profile?.displayName.orEmpty() + publicMessageField = profile?.publicMessage.orEmpty() + avatarUrlField = profile?.avatarUrl.orEmpty() + saveError = null + } + isEditing = !isEditing + }, + modifier = Modifier.testTag("dashpay.profile.edit"), + ) { Text(if (isEditing) "Cancel" else "Edit") } + }, + ) + }, + ) { padding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .verticalScroll(rememberScrollState()) + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (isEditing) { + FormSection(title = "Edit profile") { + OutlinedTextField( + value = displayNameField, + onValueChange = { displayNameField = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Display name") }, + singleLine = true, + ) + OutlinedTextField( + value = publicMessageField, + onValueChange = { publicMessageField = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Public message") }, + ) + OutlinedTextField( + value = avatarUrlField, + onValueChange = { avatarUrlField = it }, + modifier = Modifier.fillMaxWidth(), + label = { Text("Avatar URL") }, + singleLine = true, + ) + saveError?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + SubmitButton( + text = "Save", + isLoading = isSaving, + enabled = !isSaving, + modifier = Modifier.fillMaxWidth(), + ) { + val m = manager ?: return@SubmitButton + val w = wallet ?: return@SubmitButton + isSaving = true + saveError = null + scope.launch { + try { + w.dashpay.createOrUpdateProfile( + identityId = idBytes, + displayName = displayNameField.trim().ifEmpty { null }, + publicMessage = publicMessageField.trim().ifEmpty { null }, + avatarUrl = avatarUrlField.trim().ifEmpty { null }, + doCreate = !profileExists, + signerHandle = m.signerHandle, + ) + loadProfile() + isEditing = false + } catch (e: Exception) { + saveError = e.message ?: "Save failed" + } finally { + isSaving = false + } + } + } + } + } else { + FormSection { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 12.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + DashPayAvatar(profile?.avatarUrl, displayName, size = 96.dp) + Text(displayName, style = MaterialTheme.typography.titleLarge) + (identity?.mainDpnsName ?: identity?.dpnsName)?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.primary) + } + profile?.publicMessage?.trim()?.takeIf { it.isNotEmpty() }?.let { + Text(it, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center) + } + } + } + } + + FormSection(title = "Identity") { + Text( + Base58.encode(idBytes), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + + FormSection(title = "Add me (DIP-15 QR)") { + val uri = qrUri + when { + uri != null -> { + val bitmap = qrBitmap + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (bitmap != null) { + Image( + bitmap = bitmap.asImageBitmap(), + contentDescription = "Auto-accept QR", + modifier = Modifier + .size(200.dp) + .background(Color.White, RoundedCornerShape(12.dp)) + .padding(8.dp), + ) + } + Text( + "Scan to send me a contact request — auto-accepted for 1 hour.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center, + ) + Text( + uri, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + maxLines = 2, + modifier = Modifier.testTag("dashpay.profile.qrURI"), + ) + } + } + qrError != null -> Text( + qrError!!, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.tertiary, + ) + else -> Row( + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(18.dp), strokeWidth = 2.dp) + Text("Generating QR…", style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + } + } + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt new file mode 100644 index 00000000000..0080bbdb3a7 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt @@ -0,0 +1,34 @@ +package org.dashfoundation.example.ui.dashpay + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.wallet.PlatformWalletManager + +/** + * Shared DashPay sync helpers — port of the free functions in + * `ContactsView.swift` (`attachOrStartSync` / `kickDashPaySync`), used by + * the DashPay hub, Contacts, and Requests screens. + */ + +/** + * Pull-to-refresh sync: if a sweep is already in flight, attach to it (wait + * for [PlatformWalletManager.dashPaySyncIsSyncing] to clear) instead of + * double-firing; otherwise run one pass. ← Swift `attachOrStartSync`. + */ +suspend fun attachOrStartSync(manager: PlatformWalletManager) { + if (manager.dashPaySyncIsSyncing.value) { + manager.dashPaySyncIsSyncing.first { !it } + } else { + runCatching { manager.dashPaySyncNow() } + } +} + +/** + * Fire-and-forget kick of a sweep pass after a local mutation (send / + * accept / pay) so the user isn't left on a stale list. The Rust manager + * folds an in-flight pass into a no-op. ← Swift `kickDashPaySync`. + */ +fun kickDashPaySync(scope: CoroutineScope, manager: PlatformWalletManager) { + scope.launch { runCatching { manager.dashPaySyncNow() } } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt new file mode 100644 index 00000000000..fac43a6e24d --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -0,0 +1,389 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Block +import androidx.compose.material.icons.filled.Group +import androidx.compose.material.icons.filled.Inbox +import androidx.compose.material.icons.filled.Lock +import androidx.compose.material.icons.filled.Person +import androidx.compose.material.icons.filled.PersonAdd +import androidx.compose.material.icons.filled.Refresh +import androidx.compose.material.icons.filled.VisibilityOff +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Button +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.pulltorefresh.PullToRefreshBox +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import org.dashfoundation.dashsdk.wallet.DashPayUnlockStatus +import org.dashfoundation.dashsdk.wallet.ManagedPlatformWallet +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.navigation.DashPayAddContact +import org.dashfoundation.example.navigation.DashPayContacts +import org.dashfoundation.example.navigation.DashPayHidden +import org.dashfoundation.example.navigation.DashPayIgnored +import org.dashfoundation.example.navigation.DashPayProfile +import org.dashfoundation.example.navigation.DashPayRequests +import org.dashfoundation.example.navigation.IdentitiesHome +import org.dashfoundation.example.navigation.WalletsHome +import org.dashfoundation.example.ui.components.AccessiblePicker +import org.dashfoundation.example.ui.components.EntityRow +import org.dashfoundation.example.ui.components.ErrorAlertDialog +import org.dashfoundation.example.ui.components.FormSection +import org.dashfoundation.example.ui.components.LabeledContent +import org.dashfoundation.example.util.Base58 +import org.dashfoundation.example.util.formatDuffs +import org.dashfoundation.example.util.toHex + +/** + * DashPay tab root — port of `DashPayTabView.swift`. Picks the active + * wallet-backed identity, surfaces the received-from-contacts balance and the + * seedless-unlock banner, and hosts the DashPay sections (Contacts, Requests, + * Add Contact, Your Profile, Ignored, Hidden). Pull-to-refresh and the + * toolbar refresh both drive one `dashPaySyncNow()` sweep. + * + * Structural note: iOS embeds Contacts/Requests as a segmented control inside + * this view; the Kotlin hub instead navigates to per-section routes (a + * cleaner Compose fit), so `dashpay.segment` / `dashpay.profileHeader` / + * `dashpay.usernamePrompt` are not reproduced here. + * + * Deviation: the active-identity selection is in-memory (`remember(network)`), + * so it resets on a network switch (matching iOS) but is not persisted across + * launches (iOS uses `@AppStorage`). + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashPayTabScreen(navController: NavHostController) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val scope = rememberCoroutineScope() + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val walletsMap by remember(manager) { + manager?.wallets ?: MutableStateFlow(emptyMap()) + }.collectAsStateWithLifecycle() + + val allWalletOwned by remember(network) { + container.database.identityDao().observeWalletOwnedByNetwork(network.ffiValue) + }.collectAsStateWithLifecycle(emptyList()) + + // Eligible = wallet-backed identities whose wallet is loaded (← Swift + // eligibleIdentities). Sorted by creation for a stable picker order. + val eligible = remember(allWalletOwned, walletsMap) { + allWalletOwned + .filter { it.walletId != null && walletsMap.containsKey(it.walletId!!.toHex()) } + .sortedBy { it.createdAt.time } + } + + var selectedIdBase58 by remember(network) { mutableStateOf(null) } + val activeIdentity = remember(eligible, selectedIdBase58) { + eligible.firstOrNull { Base58.encode(it.identityId) == selectedIdBase58 } ?: eligible.firstOrNull() + } + + var isRefreshing by remember { mutableStateOf(false) } + var unlockError by remember { mutableStateOf(null) } + + fun refresh() { + scope.launch { + isRefreshing = true + manager?.let { runCatching { it.dashPaySyncNow() } } + isRefreshing = false + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("DashPay") }, + actions = { + IconButton(onClick = { refresh() }, modifier = Modifier.testTag("dashpay.refresh")) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + ) + }, + ) { padding -> + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { refresh() }, + modifier = Modifier.fillMaxSize().padding(padding), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(16.dp) + .testTag("dashpay.tab"), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + when { + walletsMap.isEmpty() -> EmptyState( + title = "No wallet loaded", + message = "Load or create a wallet to use DashPay.", + buttonTitle = "Open Wallets", + buttonTestTag = "dashpay.openWallets", + onClick = { navController.navigate(WalletsHome) }, + ) + eligible.isEmpty() || activeIdentity == null -> EmptyState( + title = "No identities yet", + message = "Register an identity to start using DashPay.", + buttonTitle = "Open Identities", + buttonTestTag = "dashpay.openIdentities", + onClick = { navController.navigate(IdentitiesHome) }, + ) + else -> { + val identity = activeIdentity + val identityHex = identity.identityId.toHex() + val managed = identity.walletId?.let { manager?.wallet(forWalletId = it) } + + if (eligible.size > 1) { + AccessiblePicker( + label = "Identity", + options = eligible, + selected = identity, + optionLabel = { pickerLabel(it.identityId, it.mainDpnsName ?: it.dpnsName) }, + testTag = "dashpay.identityPicker", + ) { selectedIdBase58 = Base58.encode(it.identityId) } + } + + BalanceRow( + manager = manager, + walletId = identity.walletId, + identityId = identity.identityId, + ) + + UnlockBanner( + manager = manager, + walletIdHex = identity.walletId?.toHex(), + managed = managed, + onError = { unlockError = it }, + ) + + FormSection(title = "DashPay") { + EntityRow( + icon = Icons.Default.Group, + title = "Contacts", + onClick = { navController.navigate(DashPayContacts(identityHex)) }, + modifier = Modifier.testTag("dashpay.openContacts"), + ) + EntityRow( + icon = Icons.Default.Inbox, + title = "Requests", + onClick = { navController.navigate(DashPayRequests(identityHex)) }, + modifier = Modifier.testTag("dashpay.openRequests"), + ) + EntityRow( + icon = Icons.Default.PersonAdd, + title = "Add Contact", + onClick = { navController.navigate(DashPayAddContact(identityHex)) }, + modifier = Modifier.testTag("dashpay.addContact"), + ) + EntityRow( + icon = Icons.Default.Person, + title = "Your Profile", + onClick = { navController.navigate(DashPayProfile(identityHex)) }, + modifier = Modifier.testTag("dashpay.openProfile"), + ) + EntityRow( + icon = Icons.Default.Block, + title = "Ignored", + onClick = { navController.navigate(DashPayIgnored(identityHex)) }, + modifier = Modifier.testTag("dashpay.openIgnored"), + ) + EntityRow( + icon = Icons.Default.VisibilityOff, + title = "Hidden", + onClick = { navController.navigate(DashPayHidden(identityHex)) }, + modifier = Modifier.testTag("dashpay.openHidden"), + ) + } + } + } + } + } + } + + ErrorAlertDialog(message = unlockError, onDismiss = { unlockError = null }) +} + +@Composable +private fun BalanceRow( + manager: org.dashfoundation.dashsdk.wallet.PlatformWalletManager?, + walletId: ByteArray?, + identityId: ByteArray, +) { + var receivedDuffs by remember(walletId, identityId) { mutableStateOf(0L) } + // Re-read after each completed sweep so received funds appear without a + // screen reopen (the balance is an in-memory Rust snapshot pull-refresh + // advances). + val syncingFlow = remember(manager) { manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false) } + val isSyncing by syncingFlow.collectAsStateWithLifecycle(false) + LaunchedEffect(manager, walletId, identityId, isSyncing) { + val m = manager + receivedDuffs = if (m != null && walletId != null) { + parseAccountBalances(m.accountBalances(walletId)) + .filter { it.typeTag == 12 && it.userIdentityId.contentEquals(identityId) } + .sumOf { it.confirmed + it.unconfirmed } + } else { + 0L + } + } + FormSection { + LabeledContent( + label = "Received from contacts", + value = formatDuffs(receivedDuffs), + modifier = Modifier.testTag("dashpay.receivedBalance"), + ) + } +} + +@Composable +private fun UnlockBanner( + manager: org.dashfoundation.dashsdk.wallet.PlatformWalletManager?, + walletIdHex: String?, + managed: ManagedPlatformWallet?, + onError: (String) -> Unit, +) { + // Keep every composable call unconditional (collect / scope / state), then + // render conditionally on the resolved status — avoids the fragile + // early-return-before-composable-calls pattern. + val statusFlow = remember(manager) { + manager?.dashPayUnlockStatus ?: MutableStateFlow(emptyMap()) + } + val statusMap by statusFlow.collectAsStateWithLifecycle() + val scope = rememberCoroutineScope() + var isUnlocking by remember { mutableStateOf(false) } + + val status = walletIdHex?.let { statusMap[it] } + if (manager == null || status == null) return + val hasSignal = status.draining || status.seedMismatch || status.pendingAccountBuilds > 0 + if (!hasSignal) return + + when { + status.seedMismatch -> BannerRow( + icon = Icons.Default.Warning, + tint = MaterialTheme.colorScheme.error, + text = "Seed verification failed — this wallet's Keystore seed doesn't match. " + + "DashPay signing is disabled.", + action = null, + ) + status.draining -> BannerRow( + icon = Icons.Default.Lock, + tint = MaterialTheme.colorScheme.tertiary, + text = "Finishing contact setup…", + action = null, + ) + status.pendingAccountBuilds > 0 -> { + val n = status.pendingAccountBuilds + BannerRow( + icon = Icons.Default.Lock, + tint = MaterialTheme.colorScheme.tertiary, + text = "$n contact${if (n == 1) "" else "s"} waiting to finish setup", + // Guard against a double-tap stacking concurrent unlocks: the + // SDK's `draining` flag only flips true after the verify, so a + // second tap in that window would launch a second drain. + actionEnabled = !isUnlocking, + action = if (managed != null) { + { + isUnlocking = true + scope.launch { + try { + val unlocked = manager.unlockWalletFromKeystore(managed) + if (!unlocked) { + onError( + "This wallet is watch-only on this device (no mnemonic in " + + "the Keystore), so contact setup can't be finished here.", + ) + } + } catch (e: Exception) { + onError(e.message ?: "Unlock failed") + } finally { + isUnlocking = false + } + } + } + } else { + null + }, + ) + } + } +} + +@Composable +private fun BannerRow( + icon: ImageVector, + tint: androidx.compose.ui.graphics.Color, + text: String, + action: (() -> Unit)?, + actionEnabled: Boolean = true, +) { + Row( + modifier = Modifier.fillMaxWidth().testTag("dashpay.unlockBanner"), + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon(icon, contentDescription = null, tint = tint) + Text(text, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) + if (action != null) { + Button(onClick = action, enabled = actionEnabled) { Text("Unlock") } + } + } +} + +@Composable +private fun EmptyState( + title: String, + message: String, + buttonTitle: String, + buttonTestTag: String, + onClick: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 32.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text(title, style = MaterialTheme.typography.titleMedium) + Text( + message, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Button(onClick = onClick, modifier = Modifier.testTag(buttonTestTag)) { Text(buttonTitle) } + } +} + +/** Picker label: DPNS name when known, else a truncated base58 id. */ +private fun pickerLabel(identityId: ByteArray, dpnsName: String?): String = + dpnsName?.takeIf { it.isNotBlank() } ?: (Base58.encode(identityId).take(12) + "…") diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt new file mode 100644 index 00000000000..64621184feb --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt @@ -0,0 +1,184 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.di.LocalAppState +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex + +/** + * Hidden established-contacts list — port of `HiddenContactsView.swift`. The + * exact complement of the Contacts list: established pairs with any row + * `contactHidden`. Unhide republishes `contactInfo` with `displayHidden = + * false` (preserving alias/note) then kicks a sync. Optimistic removal + + * per-row in-flight/error. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun HiddenContactsScreen(ownerIdentityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val appState = LocalAppState.current + val scope = rememberCoroutineScope() + val ownerBytes = remember(ownerIdentityIdHex) { ownerIdentityIdHex.hexToBytes() } + + val network by appState.currentNetwork.collectAsStateWithLifecycle() + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val metaStore = container.dashPayContactMetaStore + val metaVersion by metaStore.version.collectAsStateWithLifecycle() + + val identity by remember(ownerBytes) { + container.database.identityDao().observeByIdentityId(ownerBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + + val rows by remember(ownerBytes) { + container.database.dashpayDao().observeContactRequests(ownerBytes) + }.collectAsStateWithLifecycle(emptyList()) + val contactProfiles by remember(ownerBytes) { + container.database.dashpayDao().observeContactProfiles(ownerBytes) + }.collectAsStateWithLifecycle(emptyList()) + val profilesByHex = remember(contactProfiles) { contactProfiles.associateBy { it.contactIdentityId.toHex() } } + + var inFlightIds by remember { mutableStateOf(emptySet()) } + var removedOverlayIds by remember { mutableStateOf(emptySet()) } + var rowErrors by remember { mutableStateOf(emptyMap()) } + + // Prune the overlay once Room reflects the unhide (the rows lose their + // hidden flag): keep a hex only while its contact is still hidden, so a + // later re-hide within this screen session isn't masked. + LaunchedEffect(rows) { + val stillHidden = rows.groupBy { it.contactIdentityId.toHex() } + .filterValues { group -> group.any { it.contactHidden } }.keys + removedOverlayIds = removedOverlayIds.filter { it in stillHidden }.toSet() + } + + val hiddenContacts = remember(rows, profilesByHex, removedOverlayIds, metaVersion, network) { + rows.groupBy { it.contactIdentityId.toHex() } + .mapNotNull { (hex, group) -> + val hasOut = group.any { it.isOutgoing } + val hasIn = group.any { !it.isOutgoing } + if (!hasOut || !hasIn || group.none { it.contactHidden }) return@mapNotNull null + if (removedOverlayIds.contains(hex)) return@mapNotNull null + val contactId = group.first().contactIdentityId + val profile = profilesByHex[hex] + val alias = group.firstNotNullOfOrNull { it.contactAlias } + HiddenContactItem( + contactId = contactId, + displayName = dashPayContactDisplayName( + contactId = contactId, + alias = alias, + profileDisplayName = profile?.displayName, + dpnsLabel = metaStore.dpnsHint(network, ownerBytes, contactId), + ), + avatarUrl = profile?.avatarUrl, + alias = alias, + note = group.firstNotNullOfOrNull { it.contactNote }, + ) + } + .sortedBy { it.displayName.lowercase() } + } + + fun unhide(contact: HiddenContactItem) { + val m = manager ?: return + val w = wallet ?: return + val hex = contact.contactId.toHex() + rowErrors = rowErrors - hex + inFlightIds = inFlightIds + hex + scope.launch { + try { + w.dashpay.setContactInfo( + identityId = ownerBytes, + contactId = contact.contactId, + alias = contact.alias, + note = contact.note, + displayHidden = false, + signerHandle = m.signerHandle, + coreSignerHandle = m.mnemonicResolverHandle, + ) + removedOverlayIds = removedOverlayIds + hex + kickDashPaySync(scope, m) + } catch (e: Exception) { + rowErrors = rowErrors + (hex to "Unhide failed: ${e.message ?: "unknown error"}") + } finally { + inFlightIds = inFlightIds - hex + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Hidden") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (hiddenContacts.isEmpty()) { + item { + DashPayEmptyRow( + title = "No hidden contacts", + message = "Contacts you hide stay payable but leave your Contacts list, " + + "and are listed here so you can unhide them.", + ) + } + } else { + items(hiddenContacts, key = { it.contactId.toHex() }) { contact -> + val hex = contact.contactId.toHex() + ReversibleContactRow( + displayName = contact.displayName, + avatarUrl = contact.avatarUrl, + isInFlight = inFlightIds.contains(hex), + errorMessage = rowErrors[hex], + actionLabel = "Unhide", + actionTestTag = "dashpay.hidden.unhide", + onAction = { unhide(contact) }, + ) + } + } + } + } +} + +/** UI model for one hidden contact — carries alias/note so unhide can republish them. */ +private data class HiddenContactItem( + val contactId: ByteArray, + val displayName: String, + val avatarUrl: String?, + val alias: String?, + val note: String?, +) diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt new file mode 100644 index 00000000000..fa7cf1a6a1f --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt @@ -0,0 +1,186 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.navigation.NavHostController +import kotlinx.coroutines.launch +import org.dashfoundation.example.di.LocalAppContainer +import org.dashfoundation.example.util.hexToBytes +import org.dashfoundation.example.util.toHex + +/** + * Ignored-senders list — port of `IgnoredContactsView.swift`. Lists every + * sender this identity has ignored (per-sender mute, reversible, local-only) + * with an Un-ignore action; names/avatars resolve from the cached + * contact-profile Room rows. Optimistic removal + per-row in-flight/error. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun IgnoredContactsScreen(identityIdHex: String, navController: NavHostController) { + val container = LocalAppContainer.current + val scope = rememberCoroutineScope() + val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } + + val manager by container.walletManagerStore.activeManager.collectAsStateWithLifecycle() + val identity by remember(idBytes) { + container.database.identityDao().observeByIdentityId(idBytes) + }.collectAsStateWithLifecycle(initialValue = null) + val walletId = identity?.walletId + val wallet = remember(manager, walletId) { walletId?.let { manager?.wallet(forWalletId = it) } } + + val ignoredRows by remember(idBytes) { + container.database.dashpayDao().observeIgnoredSenders(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val contactProfiles by remember(idBytes) { + container.database.dashpayDao().observeContactProfiles(idBytes) + }.collectAsStateWithLifecycle(emptyList()) + val profilesByHex = remember(contactProfiles) { contactProfiles.associateBy { it.contactIdentityId.toHex() } } + + var inFlightIds by remember { mutableStateOf(emptySet()) } + var removedOverlayIds by remember { mutableStateOf(emptySet()) } + var rowErrors by remember { mutableStateOf(emptyMap()) } + + val visibleRows = remember(ignoredRows, removedOverlayIds) { + ignoredRows + .filter { !removedOverlayIds.contains(it.ignoredSenderId.toHex()) } + .sortedByDescending { it.ignoredAt.time } + } + + // Prune the overlay once Room reflects the un-ignore (the row is deleted): + // keep a hex only while its ignored row still exists, so a later re-ignore + // of the same sender within this screen session isn't masked. + LaunchedEffect(ignoredRows) { + val present = ignoredRows.mapTo(HashSet()) { it.ignoredSenderId.toHex() } + removedOverlayIds = removedOverlayIds.filter { it in present }.toSet() + } + + fun unignore(senderId: ByteArray) { + val w = wallet ?: return + val hex = senderId.toHex() + rowErrors = rowErrors - hex + inFlightIds = inFlightIds + hex + scope.launch { + try { + w.dashpay.unignoreContactSender(ourIdentityId = idBytes, contactIdentityId = senderId) + removedOverlayIds = removedOverlayIds + hex + } catch (e: Exception) { + rowErrors = rowErrors + (hex to "Un-ignore failed: ${e.message ?: "unknown error"}") + } finally { + inFlightIds = inFlightIds - hex + } + } + } + + Scaffold( + topBar = { + TopAppBar( + title = { Text("Ignored") }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + LazyColumn( + modifier = Modifier.fillMaxSize().padding(padding), + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (visibleRows.isEmpty()) { + item { + DashPayEmptyRow( + title = "No ignored contacts", + message = "Senders you ignore are hidden from your pending requests and " + + "listed here so you can un-ignore them.", + ) + } + } else { + items(visibleRows, key = { it.ignoredSenderId.toHex() }) { row -> + val hex = row.ignoredSenderId.toHex() + val profile = profilesByHex[hex] + ReversibleContactRow( + displayName = dashPayContactDisplayName(row.ignoredSenderId, null, profile?.displayName, null), + avatarUrl = profile?.avatarUrl, + isInFlight = inFlightIds.contains(hex), + errorMessage = rowErrors[hex], + actionLabel = "Un-ignore", + actionTestTag = "dashpay.ignored.unignore", + onAction = { unignore(row.ignoredSenderId) }, + ) + } + } + } + } +} + +/** + * Shared reversible-mute row (avatar + name + spinner|action + inline error), + * used by the Ignored and Hidden lists. + */ +@Composable +internal fun ReversibleContactRow( + displayName: String, + avatarUrl: String?, + isInFlight: Boolean, + errorMessage: String?, + actionLabel: String, + actionTestTag: String, + onAction: () -> Unit, +) { + Column( + modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(avatarUrl, displayName) + Text(displayName, style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f)) + if (isInFlight) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + OutlinedButton(onClick = onAction, modifier = Modifier.testTag(actionTestTag)) { + Text(actionLabel) + } + } + } + errorMessage?.let { + Text(it, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt new file mode 100644 index 00000000000..b2268fce6ad --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt @@ -0,0 +1,219 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import androidx.compose.foundation.text.KeyboardOptions +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.dashfoundation.dashsdk.wallet.PlatformWalletManager +import org.dashfoundation.example.ui.theme.appStatusColors +import org.dashfoundation.example.util.formatDuffs +import org.dashfoundation.example.util.parseDashToDuffs + +/** + * Send-a-Dash-payment sheet content — port of `SendDashPayPaymentSheet.swift`, + * hosted inside a `ModalBottomSheet` by [ContactDetailScreen]. Recipient row + + * amount (decimal DASH → duffs) with a balance check, then + * `dashpay.sendPayment`. On success it fires [onSent] (the payment-durability + * refresh in the parent), kicks a sync, and auto-closes after a short settle. + * + * No memo field: DashPay payments are plain Core-chain transactions with no + * on-chain memo slot (matching iOS), so `memo = null`. + */ +@Composable +fun SendDashPayPaymentSheet( + manager: PlatformWalletManager, + walletId: ByteArray, + senderIdentityId: ByteArray, + contactId: ByteArray, + contactDisplayName: String, + contactDpnsName: String?, + onSendingChange: (Boolean) -> Unit, + onSent: () -> Unit, + onClose: () -> Unit, +) { + val scope = rememberCoroutineScope() + val wallet = remember(manager, walletId) { manager.wallet(forWalletId = walletId) } + + var amountText by remember { mutableStateOf("") } + var isSending by remember { mutableStateOf(false) } + var errorMessage by remember { mutableStateOf(null) } + var successTxidHex by remember { mutableStateOf(null) } + var recipientProfile by remember { mutableStateOf(null) } + var senderBalanceDuffs by remember { mutableStateOf(null) } + + LaunchedEffect(wallet) { + val w = wallet ?: return@LaunchedEffect + recipientProfile = parseDashPayProfile(w.dashpay.getContactProfile(senderIdentityId, contactId)) + // Kotlin Balance has no `spendable`; confirmed is the spendable slice. + senderBalanceDuffs = runCatching { w.balance().confirmed }.getOrNull() + } + + val amountDuffs = remember(amountText) { parseDashToDuffs(amountText) } + val exceedsBalance = senderBalanceDuffs?.let { bal -> amountDuffs?.let { it > bal } } ?: false + val recipientName = recipientProfile?.displayName?.trim()?.takeIf { it.isNotEmpty() } + ?: contactDpnsName?.takeIf { it.isNotBlank() } + ?: contactDisplayName + val canSend = amountDuffs != null && amountDuffs > 0 && !exceedsBalance && + senderBalanceDuffs != 0L && !isSending + + fun send() { + val w = wallet ?: run { errorMessage = "No wallet available for this identity"; return } + val duffs = amountDuffs ?: return + isSending = true + onSendingChange(true) + errorMessage = null + scope.launch { + try { + // The broadcast + its post-send bookkeeping must complete even + // if the sheet is torn down mid-send: the payment leaves the + // wallet regardless (the JNI call is uncancellable), so losing + // the confirmation + durability refresh (onSent → the parent's + // refreshDashPayPayments) would invite a double-send. + withContext(NonCancellable) { + val txid = w.dashpay.sendPayment( + fromIdentityId = senderIdentityId, + toContactIdentityId = contactId, + amountDuffs = duffs, + coreSignerHandle = manager.mnemonicResolverHandle, + memo = null, + ) + successTxidHex = txid?.let { txidDisplayHex(it) } + onSent() + } + // Best-effort tail (kick a sweep + settle before auto-close). + kickDashPaySync(scope, manager) + delay(1500) + onClose() + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + errorMessage = e.message ?: "Send failed" + } finally { + isSending = false + onSendingChange(false) + } + } + } + + Column( + modifier = Modifier.fillMaxWidth().padding(horizontal = 20.dp, vertical = 12.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text("Send Dash", style = MaterialTheme.typography.titleLarge) + + // Recipient + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(10.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + DashPayAvatar(recipientProfile?.avatarUrl, recipientName) + Text(recipientName, style = MaterialTheme.typography.titleMedium) + } + + if (senderBalanceDuffs == 0L) { + Text( + "Your balance is 0 DASH — top up your wallet before sending.", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } else { + OutlinedTextField( + value = amountText, + onValueChange = { amountText = it }, + modifier = Modifier.fillMaxWidth().testTag("dashpay.send.amount"), + label = { Text("Amount (DASH)") }, + placeholder = { Text("0.001") }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Decimal), + ) + } + senderBalanceDuffs?.let { bal -> + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Text( + "Your balance", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + formatDuffs(bal), + style = MaterialTheme.typography.bodySmall, + color = if (exceedsBalance) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } + if (amountText.isNotEmpty() && amountDuffs == null) { + Text( + "Enter a valid decimal Dash amount", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } else if (exceedsBalance) { + Text( + "Amount exceeds your spendable balance", + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + ) + } + + successTxidHex?.let { hex -> + Text( + "Sent! txid: ${hex.take(16)}…", + style = MaterialTheme.typography.bodySmall, + color = appStatusColors.success, + ) + } + errorMessage?.let { msg -> + Text(msg, style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.error) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(12.dp, Alignment.End), + verticalAlignment = Alignment.CenterVertically, + ) { + TextButton( + onClick = onClose, + enabled = !isSending, + modifier = Modifier.testTag("dashpay.send.cancel"), + ) { Text("Cancel") } + if (isSending) { + CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) + } else { + androidx.compose.material3.Button( + onClick = { send() }, + enabled = canSend, + modifier = Modifier.testTag("dashpay.send.confirm"), + ) { Text("Send") } + } + } + } +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/FriendsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/FriendsScreen.kt deleted file mode 100644 index 64c7e9b259f..00000000000 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/FriendsScreen.kt +++ /dev/null @@ -1,293 +0,0 @@ -package org.dashfoundation.example.ui.identity - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.ListItem -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.unit.dp -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import androidx.navigation.NavHostController -import kotlinx.coroutines.launch -import org.dashfoundation.example.di.LocalAppContainer -import org.dashfoundation.example.ui.components.ErrorAlertDialog -import org.dashfoundation.example.ui.components.FormSection -import org.dashfoundation.example.ui.components.RecipientPicker -import org.dashfoundation.example.ui.components.RecipientSelection -import org.dashfoundation.example.ui.components.SubmitButton -import org.dashfoundation.example.ui.credits.rememberManagedWalletFor -import org.dashfoundation.example.util.hexToBytes -import org.dashfoundation.example.util.toHex - -/** - * DashPay contacts for an identity — port of `FriendsView.swift`. - * - * Hydration mirrors the Swift `loadFriends()` pipeline: sync incoming - * requests from the network (`ManagedPlatformWallet.dashpay.syncContactRequests`) - * + fetch sent, then read the three contact-id lists off a fresh managed- - * identity snapshot (`.contacts(identityId)`), falling back to the Room - * [org.dashfoundation.dashsdk.persistence.dao.DashpayDao] rows when the wallet - * isn't loaded or the network read fails. Accept is wired via - * `.acceptIncomingRequest` (the already-bridged accept over the incoming - * request handle); Ignore (the reversible per-sender local mute that - * replaced the old per-request Reject — Swift `ContactRequestsView`'s - * "Ignore" action) is wired via `.ignoreContactSender`. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun FriendsScreen(identityIdHex: String, navController: NavHostController) { - val container = LocalAppContainer.current - val idBytes = remember(identityIdHex) { identityIdHex.hexToBytes() } - val scope = rememberCoroutineScope() - - val identity by container.database.identityDao() - .observeByIdentityId(idBytes) - .collectAsStateWithLifecycle(initialValue = null) - val wallet = rememberManagedWalletFor(identity?.walletId) - - // Room fallback (populated by the platform-wallet sync persister). - val roomIncoming by container.database.dashpayDao() - .observeContactRequests(idBytes, isOutgoing = false) - .collectAsStateWithLifecycle(initialValue = emptyList()) - val roomOutgoing by container.database.dashpayDao() - .observeContactRequests(idBytes, isOutgoing = true) - .collectAsStateWithLifecycle(initialValue = emptyList()) - - // Live hydration from the managed-identity enumeration (preferred when a - // wallet is loaded). Null until the first hydrate finishes. - var incomingIds by remember { mutableStateOf?>(null) } - var outgoingIds by remember { mutableStateOf?>(null) } - var establishedIds by remember { mutableStateOf?>(null) } - - var recipient by remember { mutableStateOf(null) } - var isSending by remember { mutableStateOf(false) } - var acceptingHex by remember { mutableStateOf(null) } - var error by remember { mutableStateOf(null) } - - // Hydrate from the network + managed identity when the wallet resolves. - suspend fun hydrate() { - val w = wallet ?: return - // Sync from the network (best-effort — fall back to whatever local - // state exists, matching Swift which reads local state regardless). - runCatching { w.dashpay.syncContactRequests() } - runCatching { w.dashpay.fetchSentContactRequests(idBytes) } - runCatching { w.dashpay.contacts(idBytes) }.getOrNull()?.let { c -> - incomingIds = c.incoming - outgoingIds = c.outgoing - establishedIds = c.established - } - } - - LaunchedEffect(wallet) { - if (wallet != null) hydrate() - } - - // Effective lists: prefer the live hydration; fall back to Room rows. - val incoming = incomingIds - ?: roomIncoming.map { it.contactIdentityId } - val outgoing = outgoingIds - ?: roomOutgoing.map { it.contactIdentityId } - val established = establishedIds ?: emptyList() - - Scaffold( - topBar = { - TopAppBar( - title = { Text("Friends") }, - navigationIcon = { - IconButton(onClick = { navController.popBackStack() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .verticalScroll(rememberScrollState()) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - // ── Send a contact request (bridged) ────────────────────────── - FormSection(title = "Send Contact Request") { - val networkRaw = identity?.networkRaw - if (networkRaw == null || wallet == null) { - Text( - "Load this identity's wallet to send requests.", - style = MaterialTheme.typography.bodySmall, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } else { - RecipientPicker( - selection = recipient, - onSelectionChange = { recipient = it }, - networkRaw = networkRaw, - excludeIdentityIdHex = identityIdHex, - ) - SubmitButton( - text = "Send Request", - isLoading = isSending, - enabled = recipient != null && !isSending, - modifier = Modifier.fillMaxWidth().testTag("friends.send"), - ) { - val recipientId = recipient?.identityIdHex?.hexToBytes() - ?: return@SubmitButton - isSending = true - scope.launch { - try { - val manager = requireNotNull( - container.walletManagerStore.activeManager.value, - ) - wallet.dashpay.sendContactRequest( - senderIdentityId = idBytes, - recipientIdentityId = recipientId, - signerHandle = manager.signerHandle, - coreSignerHandle = manager.mnemonicResolverHandle, - ).close() - recipient = null - hydrate() - } catch (e: Exception) { - error = e.message ?: "Send failed" - } finally { - isSending = false - } - } - } - } - } - - // ── Incoming requests (accept + reject bridged) ─────────────── - FormSection(title = "Incoming") { - if (incoming.isEmpty()) { - EmptyRow("No incoming requests.") - } else { - incoming.forEach { senderId -> - val senderHex = senderId.toHex() - ListItem( - headlineContent = { Text(senderHex.take(16) + "…") }, - supportingContent = { Text("Wants to connect") }, - trailingContent = { - Column(horizontalAlignment = androidx.compose.ui.Alignment.End) { - TextButton( - onClick = { - val w = wallet ?: return@TextButton - acceptingHex = senderHex - scope.launch { - try { - val manager = requireNotNull( - container.walletManagerStore - .activeManager.value, - ) - val ok = w.dashpay.acceptIncomingRequest( - ourIdentityId = idBytes, - senderId = senderId, - signerHandle = manager.signerHandle, - coreSignerHandle = - manager.mnemonicResolverHandle, - ) - if (!ok) { - error = "Request from ${senderHex.take(12)}… " + - "is not in local state — sync first." - } - hydrate() - } catch (e: Exception) { - error = e.message ?: "Accept failed" - } finally { - acceptingHex = null - } - } - }, - enabled = wallet != null && acceptingHex == null, - modifier = Modifier.testTag("friends.accept.$senderHex"), - ) { Text("Accept") } - TextButton( - onClick = { - val w = wallet ?: return@TextButton - scope.launch { - try { - // Reversible per-sender local mute (DP-06); - // replaced the old per-request reject. - w.dashpay.ignoreContactSender( - ourIdentityId = idBytes, - contactIdentityId = senderId, - ) - hydrate() - } catch (e: Exception) { - error = e.message ?: "Ignore failed" - } - } - }, - modifier = Modifier.testTag("friends.ignore.$senderHex"), - ) { Text("Ignore") } - } - }, - ) - } - } - } - - // ── Outgoing requests ───────────────────────────────────────── - FormSection(title = "Outgoing") { - if (outgoing.isEmpty()) { - EmptyRow("No outgoing requests.") - } else { - outgoing.forEach { recipientId -> - ListItem( - headlineContent = { Text(recipientId.toHex().take(16) + "…") }, - supportingContent = { Text("Request sent") }, - ) - } - } - } - - // ── Established contacts ────────────────────────────────────── - FormSection(title = "Contacts") { - if (established.isEmpty()) { - EmptyRow("No established contacts.") - } else { - established.forEach { contactId -> - ListItem( - headlineContent = { Text(contactId.toHex().take(16) + "…") }, - supportingContent = { Text("Connected") }, - modifier = Modifier.testTag("friends.contact.${contactId.toHex()}"), - ) - } - } - } - } - } - - ErrorAlertDialog(message = error, onDismiss = { error = null }) -} - -@Composable -private fun EmptyRow(text: String) { - Text( - text, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) -} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt index 9b9c7e23964..80ad7e989d9 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/IdentityDetailScreen.kt @@ -34,7 +34,7 @@ import androidx.navigation.NavHostController import org.dashfoundation.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState import org.dashfoundation.example.navigation.ContestDetail -import org.dashfoundation.example.navigation.Friends +import org.dashfoundation.example.navigation.DashPayHome import org.dashfoundation.example.navigation.KeysList import org.dashfoundation.example.navigation.RegisterName import org.dashfoundation.example.navigation.SelectMainName @@ -51,7 +51,8 @@ import org.dashfoundation.example.util.hexToBytes * One identity's detail — port of `IdentityDetailView.swift`: identity info, * balance + credit actions, DPNS names (settled rows plus contested-name * rows linking into [ContestDetailScreen], with Register / Select-Main - * entries), DashPay (Friends entry), and the keys summary (View All Keys). + * entries), DashPay (opens the DashPay tab), and the keys summary + * (View All Keys). * * Contested-name rows probe each locally-known label with the bridged * `Voting.contestedResourceVoteState` read and surface the labels whose @@ -244,10 +245,10 @@ fun IdentityDetailScreen(identityIdHex: String, navController: NavHostController FormSection(title = "DashPay") { ListItem( - headlineContent = { Text("Friends") }, + headlineContent = { Text("DashPay") }, modifier = Modifier - .clickable { navController.navigate(Friends(identityIdHex)) } - .testTag("identityDetail.friends"), + .clickable { navController.navigate(DashPayHome) } + .testTag("identityDetail.dashpay"), ) } diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/settings/SettingsScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/settings/SettingsScreen.kt index 7599190ddb1..942ee48a884 100644 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/settings/SettingsScreen.kt +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/settings/SettingsScreen.kt @@ -207,9 +207,17 @@ fun SettingsScreen(navController: NavHostController) { } // Platform section — mirrors OptionsView.swift's Platform - // section (Queries / State Transitions + SDK status), with - // Diagnostics as the run-all-queries entry point. + // section (Contracts / Queries / State Transitions + SDK + // status), with Diagnostics as the run-all-queries entry point. + // Contracts is demoted here from a top-level tab, matching iOS + // hosting it as the first Platform NavigationLink. FormSection(title = "Platform") { + androidx.compose.material3.TextButton( + onClick = { navController.navigate(org.dashfoundation.example.navigation.ContractsHome) }, + modifier = Modifier.testTag("settings.contracts"), + ) { + Text("Contracts") + } androidx.compose.material3.TextButton( onClick = { navController.navigate(org.dashfoundation.example.navigation.QueriesList) }, modifier = Modifier.testTag("settings.queries"), diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index 061af19d7f0..022366eab4f 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -34,7 +34,7 @@ Status legend: | DocumentTypeDetailsView.swift | ui/contracts/DocumentTypeDetailsScreen.kt · `DocumentTypeDetail` | ported | | DocumentWithPriceView.swift | ui/contracts/DocumentWithPriceScreen.kt · `DocumentWithPrice` | ported — debounced document-id probe (price / owner / ownership badging via `Documents.fetch`), plus the purchase (`DocumentTransactions.purchase` → `platform_wallet_document_purchase`) and owner set-price (`DocumentTransactions.setPrice` → `platform_wallet_document_set_price`) submit flows that live in `PurchaseDocumentView` / the set-price sheet on iOS, hosted as one screen; entries from DocumentsScreen rows ("Price…") and the transition catalog | | DocumentsView.swift | ui/contracts/DocumentsScreen.kt · `Documents` | ported (query role; viewer role in DocumentFieldsScreen; the row-level Purchase… / Set Price… actions drill into DocumentWithPriceScreen; create / replace / delete / transfer stay unbridged — see TransitionDetailView) | -| ~~FriendsView.swift~~ (deleted upstream) | ui/identity/FriendsScreen.kt · `Friends` | **in migration** — the Swift view was deleted by PR #3841 and replaced by the first-class DashPay tab (`Views/DashPay/`, 10 views: DashPayTabView, ContactsView, ContactRequestsView, AddContactView, ContactDetailView, SendDashPayPaymentSheet, DashPayProfileView, IgnoredContactsView, HiddenContactsView, DashPayContactMeta), none of which are ported yet. FriendsScreen still covers a slice (sync / list / send / accept / ignore over the 17 bridged exports, reconciled with the post-#3841 FFI in `2298a2059f`); it is retired and superseded per `docs/dashpay/KOTLIN_MIGRATION_SPEC.md` (milestones K1–K3) | +| ~~FriendsView.swift~~ (deleted upstream) | — (retired) | retired — deleted by PR #3841 and superseded by the first-class DashPay tab; `FriendsScreen.kt` and its `Friends` route were removed in milestone K3. See the **Views/DashPay/** section below | | FundFromAssetLockPlatformAddressView.swift | ui/funding/FundFromAssetLockScreen.kt · `FundFromAssetLock` | ported — submit picks a fresh unused Platform address and funds via the now-bridged `platform_address_wallet_fund_from_asset_lock_signer` (+ resume variant on `ManagedPlatformWallet`); coordinator/progress/pending list drive the flow | | TransferPlatformAddressView.swift (ADDR-02, #3923) | ui/credits/TransferPlatformAddressScreen.kt · `TransferPlatformAddress` | ported — wallet-signed DIP-17 credit transfer via the now-bridged `platform_address_wallet_transfer` (`ManagedPlatformWallet.transferCredits`, AUTO selection, null inputs/fee-strategy); source account + destination (own-wallet / external P2PKH hash) + amount only; gate reads version-locked `minInput`/`minOutput` via `walletPlatformAddressMinAmounts`. Launched from WalletDetailScreen's Platform Credits section | | WithdrawPlatformAddressView.swift (ADDR-04, #3923) | ui/credits/WithdrawPlatformAddressScreen.kt · `WithdrawPlatformAddress` | ported — wallet-signed full-balance DIP-17 withdrawal to a Core L1 address via the now-bridged `platform_address_wallet_withdraw_to_address` (`ManagedPlatformWallet.withdrawCredits`); submit gated on `platform_address_wallet_preflight_withdrawal` (`preflightWithdrawal`, off the main thread on `Dispatchers.IO`), and when the gate refuses, the advisory "why not" from `preflightWithdrawalReason` (`platform_address_wallet_preflight_withdrawal_reason`) renders under the status row; Fibonacci fee-rate picker mirrors `WithdrawalCoreFeeRates`. Launched from WalletDetailScreen's Platform Credits section | @@ -81,6 +81,36 @@ Status legend: | WalletMemoryExplorerView.swift | ui/diagnostics/WalletMemoryExplorerScreen.kt · `WalletMemoryExplorer` | partial — wallets map, balances, SPV progress/tip, `is*SyncRunning` liveness live; per-wallet drill-downs deferred on `platform_wallet_manager_*` snapshot exports | | WithdrawCreditsView.swift | ui/credits/WithdrawCreditsScreen.kt · `WithdrawCredits` | ported | +## Views/DashPay/ + +The first-class DashPay tab added by PR #3841 (replacing the old +`FriendsView`), ported in milestone K3. All screens live under +`ui/dashpay/`; testTags reuse the iOS `dashpay.*` accessibility identifiers +verbatim. Documented deviations from iOS are listed after the table. + +| Swift file | Android file / route | Status | +| --- | --- | --- | +| DashPayTabView.swift | ui/dashpay/DashPayTabScreen.kt · `DashPayHome` | ported — wallet-backed identity picker (`observeWalletOwnedByNetwork`), received-from-contacts balance (`accountBalances` typeTag 12, re-read after each sweep), the seedless-unlock banner off `dashPayUnlockStatus` (seed-mismatch / draining / pending-account-builds → `unlockWalletFromKeystore`), pull-to-refresh + toolbar refresh (`dashPaySyncNow`). Adapted: the Contacts/Requests **segmented control is replaced by nav-section rows** (`dashpay.openContacts` / `dashpay.openRequests` / …), so `dashpay.segment` / `dashpay.profileHeader` / `dashpay.usernamePrompt` are not reproduced | +| ContactsView.swift | ui/dashpay/ContactsScreen.kt · `DashPayContacts` | ported — established-contacts both-direction grouping over Room `observeContactRequests`, hidden excluded, cached name/avatar via `observeContactProfiles` + the meta store, search, Hidden recovery link, pull-to-refresh | +| ContactRequestsView.swift | ui/dashpay/ContactRequestsScreen.kt · `DashPayRequests` | ported — incoming (Accept via `acceptIncomingRequest` / Ignore via `ignoreContactSender`, per-row in-flight + inline error + optimistic-removal overlay) and outgoing pending; incoming avatars are never loaded (IP-leak avoidance). Adapted: the cross-screen optimistic-**sent** overlay is dropped (AddContact is a separate route, not a shared `@Binding`) | +| AddContactView.swift | ui/dashpay/AddContactScreen.kt · `DashPayAddContact` | ported — 300 ms-debounced DPNS search (`searchDpnsNames`), paste-id (32-byte base58 gate), preview card, optional account label, collision dialog (Accept vs Continue anyway), and scan-to-send via `sendContactRequestFromQr` (`dashpay.addViaQR` toolbar → the shared QR scanner). Records the DPNS hint into the meta store on success | +| ContactDetailView.swift | ui/dashpay/ContactDetailScreen.kt · `DashPayContactDetail` | ported — header (cached profile + account label), Send Dash (opens the payment sheet, disabled on broken channel), Room-driven payment history (durable `refreshDashPayPayments` → `observePayments`, sorted newest-first client-side), and alias/note editors (AlertDialogs, same `dashpay.detail.{alias,note}.{field,save,cancel}` tags as the Swift sheets) + hide toggle via `setContactInfo` **surfacing the DIP-15 deferred / watch-only publish notices** | +| SendDashPayPaymentSheet.swift | ui/dashpay/SendDashPayPaymentSheet.kt | ported — hosted as a `ModalBottomSheet` from ContactDetail; amount (decimal DASH → duffs) + balance check, `sendPayment` → txid (reversed-hex via `txidDisplayHex`), then always `refreshDashPayPayments` (the durability invariant). No memo field (matches iOS — DashPay payments have no on-chain memo slot). Uses `Balance.confirmed` as spendable (Kotlin `Balance` has no `spendable`) | +| DashPayProfileView.swift | ui/dashpay/DashPayProfileScreen.kt · `DashPayProfile` | ported — read-only display (avatar / name / DPNS / public message / id) + DIP-15 auto-accept QR (`buildAutoAcceptQr` rendered with the ZXing `generateQrBitmap` helper); folds in the companion editor (inline edit mode → `createOrUpdateProfile`, doCreate when no profile exists) | +| IgnoredContactsView.swift | ui/dashpay/IgnoredContactsScreen.kt · `DashPayIgnored` | ported — `observeIgnoredSenders` list with Un-ignore (`unignoreContactSender`), optimistic removal + per-row in-flight/error | +| HiddenContactsView.swift | ui/dashpay/HiddenContactsScreen.kt · `DashPayHidden` | ported — client-side hidden-established grouping with Unhide (`setContactInfo displayHidden=false`, preserving alias/note) + sync kick | +| DashPayContactMeta.swift | ui/dashpay/DashPayContactMeta.kt (+ DashPayJson.kt) | ported — `DashPayContactMetaStore` (SharedPreferences, same key shape, `version` StateFlow), `dashPayContactDisplayName` precedence, `txidDisplayHex`, the `DashPayAvatar` composable (Coil), plus the org.json parsers for profile / DPNS-search / account-balance / payment reads | + +**Deviations from iOS (all intentional, K3):** + +1. **Hub uses nav-section rows, not a segmented control.** The DashPay tab navigates to `DashPayContacts` / `DashPayRequests` routes instead of embedding a `[Contacts | Requests]` picker — a cleaner Compose fit. `dashpay.segment` / `dashpay.profileHeader` / `dashpay.profileHeader.setup` / `dashpay.usernamePrompt` are not reproduced; Ignored + Hidden are surfaced as hub rows (iOS gated Hidden behind a Contacts link, which the Kotlin ContactsScreen also keeps). +2. **Active-identity selection is in-memory** (`remember(network)`) — resets on network switch (matching iOS) but is not persisted across launches (iOS uses `@AppStorage`). +3. **Optimistic-sent overlay dropped** — AddContact is its own route, so a just-sent request appears once the post-send sync persists its outgoing row. +4. **Incoming-request avatars are always null** (IP-leak avoidance), matching iOS. +5. **Signing** uses the manager's `signerHandle` + `mnemonicResolverHandle` directly (the KeystoreSigner behind them is auth-gated internally) — no per-screen biometric prompt. + +**Tests:** `androidTest/DashPayTabUITest.kt` ports the network-free launch-and-render flows from `DashPayTabUITests.swift` (recognized-state gating + hub entry points), adapted for the nav-section deviation. + ## Views/Components/ | Swift file | Android file | Status | @@ -129,13 +159,11 @@ Status legend: ## Totals -- **ported**: 87 (of the 90 pre-#3841 Swift views; the FriendsView row above - no longer counts — its Swift source is deleted) +- **ported**: 97 (87 pre-#3841 views + the 10 `Views/DashPay/` views, ported + in milestone K3; the retired FriendsView row does not count — its Swift + source is deleted) - **partial**: 2 (TransitionDetailView — 5 of 23 catalog entries lack backing FFIs: dataContractUpdate, documentCreate/Replace/Delete/Transfer; WalletMemoryExplorerView — asset-lock drill-down summary only) - **deferred**: 0 -- **in migration**: the DashPay tab (10 `Views/DashPay/` views added by - PR #3841) — see `docs/dashpay/KOTLIN_MIGRATION_SPEC.md`; this table gets - its `Views/DashPay/` section when milestone K3 lands Every partial/deferred row names the missing FFI export; the app surfaces the same name in a dialog at the point of use (grep for diff --git a/packages/kotlin-sdk/gradle/libs.versions.toml b/packages/kotlin-sdk/gradle/libs.versions.toml index b9a89bd7983..67d6cace340 100644 --- a/packages/kotlin-sdk/gradle/libs.versions.toml +++ b/packages/kotlin-sdk/gradle/libs.versions.toml @@ -14,6 +14,7 @@ navigationCompose = "2.9.0" camerax = "1.4.2" mlkitBarcode = "17.3.0" zxing = "3.5.3" +coil = "2.7.0" junit = "4.13.2" androidxTestExtJunit = "1.2.1" androidxTestRunner = "1.6.2" @@ -47,6 +48,7 @@ camera-lifecycle = { group = "androidx.camera", name = "camera-lifecycle", versi camera-view = { group = "androidx.camera", name = "camera-view", version.ref = "camerax" } mlkit-barcode-scanning = { group = "com.google.mlkit", name = "barcode-scanning", version.ref = "mlkitBarcode" } zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } +coil-compose = { group = "io.coil-kt", name = "coil-compose", version.ref = "coil" } junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-core = { group = "androidx.test", name = "core", version = "1.6.1" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidxTestExtJunit" } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt index b4f0fe06939..1ac52ed291e 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/ffi/DashpayNative.kt @@ -173,4 +173,37 @@ internal object DashpayNative { * ← Swift `KeychainSigner.resolverCanDeriveSign`. */ external fun resolverSupportsKeyType(keyType: Int): Boolean + + // ── DIP-15 auto-accept QR ───────────────────────────────────────── + + /** + * Build the owner's DIP-15 auto-accept QR payload + * (`dash:?du=…&dapk=…`) for [identityId], keying the proof through + * [coreSignerHandle]. [username] is the owner's DPNS name and is + * **required** — the FFI rejects a null string; pass `""` for a nameless + * identity (Rust resolves the name on-chain or errors clearly). + * ← Swift `ManagedPlatformWallet.buildAutoAcceptQR`. + */ + external fun buildAutoAcceptQr( + walletHandle: Long, + identityId: ByteArray, + username: String, + coreSignerHandle: Long, + ): String? + + /** + * Scan-to-send: parse a DIP-15 auto-accept QR [uri] and send the + * contact request it describes from [senderIdentityId] (the embedded + * proof key lets the owner auto-accept). Blocking (network). Returns + * the created `ContactRequest` handle — destroy via + * [TokensNative.contactRequestDestroy]. + * ← Swift `ManagedPlatformWallet.sendContactRequestFromQR`. + */ + external fun sendContactRequestFromQr( + walletHandle: Long, + senderIdentityId: ByteArray, + uri: String, + signerHandle: Long, + coreSignerHandle: Long, + ): Long } diff --git a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt index 3961eb73f65..5bf3b9c2e23 100644 --- a/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt +++ b/packages/kotlin-sdk/sdk/src/main/kotlin/org/dashfoundation/dashsdk/tokens/Dashpay.kt @@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import org.dashfoundation.dashsdk.errors.mapNativeErrors import org.dashfoundation.dashsdk.ffi.DashpayNative +import org.dashfoundation.dashsdk.ffi.NativeCleaner import org.dashfoundation.dashsdk.ffi.TokensNative /** @@ -319,6 +320,49 @@ class Dashpay internal constructor(private val walletHandle: Long) { mapNativeErrors { DashpayNative.searchDpnsNames(walletHandle, prefix, limit) } } + // ── DIP-15 auto-accept QR (upstream #3841 parity) ────────────────── + + /** + * Build the owner's DIP-15 auto-accept QR URI for [identityId] + * (`dash:?du=…&dapk=…`), keying the proof through [coreSignerHandle]. + * The UI renders it as a QR (ZXing). ← Swift `buildAutoAcceptQR`. + * + * [username] is the owner's DPNS name and is **required** (matching + * Swift's `username: String`): the underlying FFI rejects a null string, + * so pass `""` for a nameless identity — Rust then resolves the name + * on-chain (or surfaces a clear "no name registered" error). + */ + suspend fun buildAutoAcceptQr( + identityId: ByteArray, + username: String, + coreSignerHandle: Long, + ): String? = withContext(Dispatchers.IO) { + mapNativeErrors { + DashpayNative.buildAutoAcceptQr(walletHandle, identityId, username, coreSignerHandle) + } + } + + /** + * Scan-to-send: parse an auto-accept QR [uri] and send the contact + * request it describes from [senderIdentityId]. Blocking network + * call; runs on IO. Returns the created request wrapped as a + * [ContactRequestRef] — close it (or `use {}`) when done. + * ← Swift `sendContactRequestFromQR`. + */ + suspend fun sendContactRequestFromQr( + senderIdentityId: ByteArray, + uri: String, + signerHandle: Long, + coreSignerHandle: Long, + ): ContactRequestRef = withContext(Dispatchers.IO) { + val handle = mapNativeErrors { + DashpayNative.sendContactRequestFromQr( + walletHandle, senderIdentityId, uri, signerHandle, coreSignerHandle, + ) + } + ContactRequestRef(handle) + } + // ── Profile / contactInfo writes (upstream #3841 parity) ────────── /** @@ -442,27 +486,43 @@ enum class ContactInfoPublishOutcome(val raw: Int) { /** Owned native `ContactRequest` handle from [Dashpay.sendContactRequest]. */ class ContactRequestRef internal constructor(handle: Long) : AutoCloseable { private val handleRef = AtomicLong(handle) + private val cleanable = NativeCleaner.register(this, HandleCleanup(handleRef)) internal val value: Long get() = handleRef.get().also { check(it != 0L) { "ContactRequestRef has been closed" } } - /** Idempotent: the [AtomicLong] swap destroys the handle exactly once. */ + /** Idempotent: destroys the handle exactly once, on [close] or the GC backstop. */ override fun close() { - val h = handleRef.getAndSet(0) - if (h != 0L) TokensNative.contactRequestDestroy(h) + cleanable.clean() + } + + /** Runs on [NativeCleaner] or [close]; destroys the handle exactly once. */ + private class HandleCleanup(private val handleRef: AtomicLong) : Runnable { + override fun run() { + val h = handleRef.getAndSet(0) + if (h != 0L) TokensNative.contactRequestDestroy(h) + } } } /** Owned native `EstablishedContact` handle from [Dashpay.acceptContactRequest]. */ class EstablishedContactRef internal constructor(handle: Long) : AutoCloseable { private val handleRef = AtomicLong(handle) + private val cleanable = NativeCleaner.register(this, HandleCleanup(handleRef)) internal val value: Long get() = handleRef.get().also { check(it != 0L) { "EstablishedContactRef has been closed" } } - /** Idempotent: the [AtomicLong] swap destroys the handle exactly once. */ + /** Idempotent: destroys the handle exactly once, on [close] or the GC backstop. */ override fun close() { - val h = handleRef.getAndSet(0) - if (h != 0L) TokensNative.establishedContactDestroy(h) + cleanable.clean() + } + + /** Runs on [NativeCleaner] or [close]; destroys the handle exactly once. */ + private class HandleCleanup(private val handleRef: AtomicLong) : Runnable { + override fun run() { + val h = handleRef.getAndSet(0) + if (h != 0L) TokensNative.establishedContactDestroy(h) + } } } diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 34ce31b6db4..15d4a55a662 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -807,3 +807,88 @@ fn opt_jstring_to_cstring(env: &mut JNIEnv, s: &JString) -> Option jstring { + guard(&mut env, ptr::null_mut(), |env| { + let Some(id) = read_id32(env, &identity_id, "identityId") else { + return ptr::null_mut(); + }; + let username_c = opt_jstring_to_cstring(env, &username); + let mut uri: *mut c_char = ptr::null_mut(); + let result = unsafe { + platform_wallet_ffi::platform_wallet_build_auto_accept_qr( + wallet_handle as Handle, + id.as_ptr(), + username_c.as_ref().map_or(ptr::null(), |c| c.as_ptr()), + core_signer_handle as *mut MnemonicResolverHandle, + &mut uri as *mut *mut c_char, + ) + }; + if take_pwffi_error(env, result) { + return ptr::null_mut(); + } + let value = unsafe { opt_cstr(uri) }.unwrap_or_default(); + unsafe { platform_wallet_ffi::platform_wallet_string_free(uri) }; + new_jstring(env, value) + }) +} + +/// Scan-to-send: parse a DIP-15 auto-accept QR `uri` and send the +/// contact request it describes from `senderIdentityId` (bridges +/// `platform_wallet_send_contact_request_from_qr`) — the recipient's +/// embedded proof key lets the owner auto-accept. Blocking (network). +/// Returns the created `ContactRequest` handle (destroy via +/// `TokensNative.contactRequestDestroy`). +#[no_mangle] +pub extern "system" fn Java_org_dashfoundation_dashsdk_ffi_DashpayNative_sendContactRequestFromQr( + mut env: JNIEnv, + _class: JClass, + wallet_handle: jlong, + sender_identity_id: JByteArray, + uri: JString, + signer_handle: jlong, + core_signer_handle: jlong, +) -> jlong { + guard(&mut env, 0, |env| { + let Some(id) = read_id32(env, &sender_identity_id, "senderIdentityId") else { + return 0; + }; + let Some(uri_c) = opt_jstring_to_cstring(env, &uri) else { + crate::support::throw_sdk_exception(env, 1, "uri must not be null"); + return 0; + }; + let mut request_handle: Handle = 0; + let result = unsafe { + platform_wallet_ffi::platform_wallet_send_contact_request_from_qr( + wallet_handle as Handle, + id.as_ptr(), + uri_c.as_ptr(), + signer_handle as *mut SignerHandle, + core_signer_handle as *mut MnemonicResolverHandle, + &mut request_handle as *mut Handle, + ) + }; + if take_pwffi_error(env, result) { + return 0; + } + request_handle as jlong + }) +}