From 35d608a4b4ce14435024a9fe30762e2b0596383c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 22:20:57 +0700 Subject: [PATCH 1/5] feat(kotlin-sdk): bridge the DIP-15 auto-accept QR pair (K3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit platform_wallet_build_auto_accept_qr and platform_wallet_send_contact_request_from_qr, first consumed by the K3 DashPay tab (profile QR display + scan-to-send in AddContact) — the last two of the migration's bridged exports. Kotlin surface: Dashpay.buildAutoAcceptQr / sendContactRequestFromQr (returns a ContactRequestRef). QR generation/parsing/proof crypto is entirely Rust-side (auto_accept.rs); the app only renders/scans the URI. Co-Authored-By: Claude Fable 5 --- .../dashsdk/ffi/DashpayNative.kt | 31 +++++++ .../dashfoundation/dashsdk/tokens/Dashpay.kt | 38 +++++++++ packages/rs-unified-sdk-jni/src/dashpay.rs | 82 +++++++++++++++++++ 3 files changed, 151 insertions(+) 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 b4f0fe0693..1bc1bb1a3b 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,35 @@ 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 a display hint (nullable). + * ← 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 3961eb73f6..d140cd1c55 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 @@ -319,6 +319,44 @@ 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`. + */ + suspend fun buildAutoAcceptQr( + identityId: ByteArray, + username: String? = null, + 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) ────────── /** diff --git a/packages/rs-unified-sdk-jni/src/dashpay.rs b/packages/rs-unified-sdk-jni/src/dashpay.rs index 34ce31b6db..ce64501ca9 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -807,3 +807,85 @@ 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 + }) +} From 2f646c2cc54ce7fcc1af62e37858ff1f13296c3c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 22:27:07 +0700 Subject: [PATCH 2/5] feat(kotlin-sdk): DashPay tab navigation + contact meta infra (K3 slice A) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the KotlinExampleApp bottom tabs to make DashPay first-class, mirroring the iOS ContentView.swift RootTab (SYNC, WALLETS, IDENTITIES, DASHPAY, SETTINGS), and land the device-local contact-meta + avatar infra the DashPay screens (slices B/C) build on. UI hub itself is a placeholder here. - Coil: add io.coil-kt:coil-compose (the app's one approved new dependency) for avatar loading — libs.versions.toml + app build.gradle.kts. - Navigation: RootTab CONTRACTS -> DASHPAY (route DashPayHome, testTag rootTab.dashpay, Group icon, "DashPay" label). Contracts is demoted into Settings' Platform section (first row, testTag settings.contracts, navigating the shared ContractsHome route) exactly as iOS OptionsView hosts it. Register a placeholder DashPayTabScreen (testTag dashpay.tab.root) so navigation compiles; slice B replaces it. - Retire FriendsScreen: delete the screen, its Friends route, and repoint its only entry point (IdentityDetailScreen's DashPay row) to the DashPay tab. FriendsView.swift was deleted upstream. - DashPayContactMeta port (<- DashPayContactMeta.swift): SharedPreferences- backed DashPayContactMetaStore with the same key shape (dashpay.meta....) and a version StateFlow for Compose invalidation, the dashPayContactDisplayName precedence helper, and a Coil-backed DashPayAvatar with an initials-circle fallback. - Lifecycle: start/stop DashPay sync in AppContainer.rebindWalletScopedServices alongside the address/shielded loops, matching iOS. - Update AppSmokeTest tab list (rootTab.contracts -> rootTab.dashpay). Build gate: :app:compileDebugKotlin + :sdk:compileDebugKotlin both pass. Co-Authored-By: Claude Fable 5 --- .../KotlinExampleApp/app/build.gradle.kts | 1 + .../dashfoundation/example/AppSmokeTest.kt | 2 +- .../dashfoundation/example/di/AppContainer.kt | 8 + .../example/navigation/AppNavHost.kt | 11 +- .../example/navigation/Routes.kt | 7 +- .../dashfoundation/example/ui/MainScreen.kt | 6 +- .../example/ui/dashpay/DashPayContactMeta.kt | 172 ++++++++++ .../example/ui/dashpay/DashPayTabScreen.kt | 45 +++ .../example/ui/identity/FriendsScreen.kt | 293 ------------------ .../ui/identity/IdentityDetailScreen.kt | 11 +- .../example/ui/settings/SettingsScreen.kt | 12 +- packages/kotlin-sdk/gradle/libs.versions.toml | 2 + 12 files changed, 256 insertions(+), 314 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt delete mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/identity/FriendsScreen.kt diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts b/packages/kotlin-sdk/KotlinExampleApp/app/build.gradle.kts index 0a4511fe85..b5607f9e8a 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 d53585acdb..557d6374ec 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/main/java/org/dashfoundation/example/di/AppContainer.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/di/AppContainer.kt index 5bd47fa32b..c213bf3c58 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 @@ -183,6 +183,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 +210,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 05bb11f287..826edb0d4b 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,8 @@ 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.DashPayTabScreen 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 +164,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 +361,10 @@ fun AppNavHost( CoSignProposalScreen(entry.toRoute(), navController) } + // ── DashPay graph ────────────────────────────────────────────── + + composable { DashPayTabScreen(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 23c37bd28d..d5ad94560f 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) @@ -345,6 +344,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 7340d9924a..8a7b4213bc 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/DashPayContactMeta.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt new file mode 100644 index 0000000000..3941ea1053 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt @@ -0,0 +1,172 @@ +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) + "…" +} + +// ── 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/DashPayTabScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt new file mode 100644 index 0000000000..b72950db9c --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayTabScreen.kt @@ -0,0 +1,45 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.navigation.NavHostController + +/** + * DashPay tab root — placeholder for the port of `DashPayTabView.swift`. + * + * The DASHPAY tab is first-class as of this milestone; the contacts / + * requests / profile hub replaces this empty state in the next slice. The + * [navController] is accepted now so the hub's child navigation attaches + * without a route re-registration. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun DashPayTabScreen(navController: NavHostController) { + Scaffold( + topBar = { TopAppBar(title = { Text("DashPay") }) }, + ) { padding -> + Box( + modifier = Modifier + .fillMaxSize() + .padding(padding) + .testTag("dashpay.tab.root"), + contentAlignment = Alignment.Center, + ) { + Text( + "DashPay contacts arrive in the next milestone.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + } +} 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 64c7e9b259..0000000000 --- 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 9b9c7e2396..80ad7e989d 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 7599190ddb..942ee48a88 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/gradle/libs.versions.toml b/packages/kotlin-sdk/gradle/libs.versions.toml index b9a89bd798..67d6cace34 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" } From b30dcc4c4d47b7df51041d4cb742097f7bc6c64c Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 22:50:42 +0700 Subject: [PATCH 3/5] feat(kotlin-sdk): DashPay hub, contacts, requests and add-contact screens (K3 slice B) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the four interactive DashPay screens the tab is built around, plus the JSON parsers and sync helpers the whole family shares. - DashPayJson.kt: data classes + org.json parsers for DashPayProfile, DpnsSearchResult, AccountBalance and DashPayPayment (slice C consumes the payment parser) — total/lenient, per-row failures skipped. - DashPayTabScreen.kt: replaces the slice-A placeholder. Wallet-backed identity picker (in-memory, resets on network switch), received-from- contacts balance (accountBalances typeTag 12, re-read after each sweep), the seedless-unlock banner off dashPayUnlockStatus (seed-mismatch / draining / pending-account-builds → unlockWalletFromKeystore), and EntityRow sections navigating to the per-section routes. Pull-to-refresh and a toolbar refresh both run dashPaySyncNow(). - ContactsScreen.kt: established-contacts (both-direction join, hidden excluded) from Room, searchable, avatar + display-name precedence, Hidden recovery link, pull-to-refresh. - ContactRequestsScreen.kt: incoming (Accept via acceptIncomingRequest / Ignore via ignoreContactSender, per-row in-flight + inline error + optimistic-removal overlay) and outgoing pending; incoming avatars are never loaded (IP-leak avoidance). - AddContactScreen.kt: 300 ms-debounced DPNS search, paste-id (32-byte base58 gate), preview card, optional account label, collision dialog (Accept vs Continue anyway), and scan-to-send via sendContactRequestFromQr. - DashPaySyncHelpers.kt: attachOrStartSync / kickDashPaySync. - Routes.kt + AppNavHost.kt: DashPay child routes + graph wiring. - Stub screens (ContactDetail / Profile / Ignored / Hidden) with fixed signatures + routes so the hub navigates end-to-end now; K3 slice C replaces their bodies. Structural deviation from iOS: the hub navigates to per-section routes rather than embedding a Contacts/Requests segmented control, and the cross-screen optimistic-sent overlay is dropped (Add Contact is its own route). Signing uses the manager's signer + mnemonic-resolver handles directly (the retired FriendsScreen's approach). Build gate: :app:compileDebugKotlin passes. Co-Authored-By: Claude Fable 5 --- .../example/navigation/AppNavHost.kt | 36 ++ .../example/navigation/Routes.kt | 26 + .../example/ui/dashpay/AddContactScreen.kt | 531 ++++++++++++++++++ .../example/ui/dashpay/ContactDetailScreen.kt | 23 + .../ui/dashpay/ContactRequestsScreen.kt | 359 ++++++++++++ .../example/ui/dashpay/ContactsScreen.kt | 291 ++++++++++ .../example/ui/dashpay/DashPayJson.kt | 179 ++++++ .../ui/dashpay/DashPayProfileScreen.kt | 20 + .../example/ui/dashpay/DashPayStub.kt | 57 ++ .../example/ui/dashpay/DashPaySyncHelpers.kt | 34 ++ .../example/ui/dashpay/DashPayTabScreen.kt | 356 +++++++++++- .../ui/dashpay/HiddenContactsScreen.kt | 20 + .../ui/dashpay/IgnoredContactsScreen.kt | 20 + 13 files changed, 1935 insertions(+), 17 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPaySyncHelpers.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt 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 826edb0d4b..2a86209155 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,7 +28,14 @@ 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.IdentitiesHomeScreen import org.dashfoundation.example.ui.identity.IdentityDetailScreen @@ -365,6 +372,35 @@ fun AppNavHost( 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 d5ad94560f..dba982f879 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 @@ -173,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`). */ 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 0000000000..ccf9e290ea --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/AddContactScreen.kt @@ -0,0 +1,531 @@ +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.LocalContext +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 context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + + 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 0000000000..76200eec8c --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt @@ -0,0 +1,23 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.navigation.NavHostController + +/** + * One contact's detail + payments — placeholder for the port of + * `ContactDetailView.swift`. Replaced by the K3 slice C implementation + * (header / Send Dash / payment history / local alias-note-hide settings); + * the signature and route ([org.dashfoundation.example.navigation.DashPayContactDetail]) + * are fixed here so ContactsScreen's row navigation compiles now. + */ +@androidx.compose.runtime.Composable +fun ContactDetailScreen( + identityIdHex: String, + contactIdHex: String, + navController: NavHostController, +) { + DashPayStubScaffold( + title = "Contact", + rootTestTag = "dashpay.detail.root", + navController = navController, + ) +} 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 0000000000..ba1f2b9743 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactRequestsScreen.kt @@ -0,0 +1,359 @@ +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.LocalContext +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.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 context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + 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) } + + val isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) + .collectAsStateWithLifecycle(false) + + // Prune the optimistic-removal overlay once the query reflects the change + // (accept promotes to established, ignore deletes the row). + 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() + } + // Fallback clear: after the next completed sweep, expire whatever the + // query still hasn't reflected so no row stays hidden forever. + LaunchedEffect(isSyncing) { + if (!isSyncing) removedOverlayIds = emptySet() + } + + 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 0000000000..48138fc354 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactsScreen.kt @@ -0,0 +1,291 @@ +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.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.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 context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + 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/DashPayJson.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayJson.kt new file mode 100644 index 0000000000..cab8612404 --- /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 0000000000..784c07cd6a --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayProfileScreen.kt @@ -0,0 +1,20 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController + +/** + * Read-only own-profile view + DIP-15 auto-accept QR — placeholder for the + * port of `DashPayProfileView.swift`. Replaced by the K3 slice C + * implementation; the signature + route + * ([org.dashfoundation.example.navigation.DashPayProfile]) are fixed here so + * the hub's "Your Profile" entry compiles now. + */ +@Composable +fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController) { + DashPayStubScaffold( + title = "Your Profile", + rootTestTag = "dashpay.profile.root", + navController = navController, + ) +} diff --git a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt new file mode 100644 index 0000000000..3e294f7957 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt @@ -0,0 +1,57 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +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.MaterialTheme +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.navigation.NavHostController + +/** + * Shared placeholder scaffold for the DashPay screens still owned by K3 + * slice C (ContactDetail / Profile / Ignored / Hidden). Gives each a titled + * bar + back button + a root testTag so navigation from the hub / Contacts + * works end-to-end before those screens land. + */ +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun DashPayStubScaffold( + title: String, + rootTestTag: String, + navController: NavHostController, +) { + Scaffold( + topBar = { + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = { navController.popBackStack() }) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") + } + }, + ) + }, + ) { padding -> + Box( + modifier = Modifier.fillMaxSize().padding(padding).testTag(rootTestTag), + contentAlignment = Alignment.Center, + ) { + Text( + "Coming in the next milestone.", + style = MaterialTheme.typography.bodyMedium, + 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 0000000000..0080bbdb3a --- /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 index b72950db9c..5b9040fdd8 100644 --- 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 @@ -1,45 +1,367 @@ package org.dashfoundation.example.ui.dashpay -import androidx.compose.foundation.layout.Box +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.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 — placeholder for the port of `DashPayTabView.swift`. + * 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. * - * The DASHPAY tab is first-class as of this milestone; the contacts / - * requests / profile hub replaces this empty state in the next slice. The - * [navController] is accepted now so the hub's child navigation attaches - * without a route re-registration. + * 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") }) }, + topBar = { + TopAppBar( + title = { Text("DashPay") }, + actions = { + IconButton(onClick = { refresh() }, modifier = Modifier.testTag("dashpay.refresh")) { + Icon(Icons.Default.Refresh, contentDescription = "Refresh") + } + }, + ) + }, ) { padding -> - Box( - modifier = Modifier - .fillMaxSize() - .padding(padding) - .testTag("dashpay.tab.root"), - contentAlignment = Alignment.Center, + PullToRefreshBox( + isRefreshing = isRefreshing, + onRefresh = { refresh() }, + modifier = Modifier.fillMaxSize().padding(padding), ) { - Text( - "DashPay contacts arrive in the next milestone.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, + 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 isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) + .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, +) { + val m = manager ?: return + val statusMap by m.dashPayUnlockStatus.collectAsStateWithLifecycle() + val status = walletIdHex?.let { statusMap[it] } ?: return + val hasSignal = status.draining || status.seedMismatch || status.pendingAccountBuilds > 0 + if (!hasSignal) return + val scope = rememberCoroutineScope() + + 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", + action = if (managed != null) { + { + scope.launch { + try { + val unlocked = m.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") + } + } + } + } else { + null + }, ) } } } + +@Composable +private fun BannerRow(icon: ImageVector, tint: androidx.compose.ui.graphics.Color, text: String, action: (() -> Unit)?) { + 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) { 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 0000000000..64f40bf97c --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/HiddenContactsScreen.kt @@ -0,0 +1,20 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController + +/** + * Hidden established-contacts list (reversible, cross-device) — placeholder + * for the port of `HiddenContactsView.swift`. Replaced by the K3 slice C + * implementation; the signature + route + * ([org.dashfoundation.example.navigation.DashPayHidden]) are fixed here so + * ContactsScreen's "Hidden contacts" link and the hub entry compile now. + */ +@Composable +fun HiddenContactsScreen(ownerIdentityIdHex: String, navController: NavHostController) { + DashPayStubScaffold( + title = "Hidden", + rootTestTag = "dashpay.hidden.root", + navController = navController, + ) +} 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 0000000000..3018463a63 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/IgnoredContactsScreen.kt @@ -0,0 +1,20 @@ +package org.dashfoundation.example.ui.dashpay + +import androidx.compose.runtime.Composable +import androidx.navigation.NavHostController + +/** + * Ignored-senders list (per-sender mute, reversible) — placeholder for the + * port of `IgnoredContactsView.swift`. Replaced by the K3 slice C + * implementation; the signature + route + * ([org.dashfoundation.example.navigation.DashPayIgnored]) are fixed here so + * the hub's "Ignored" entry compiles now. + */ +@Composable +fun IgnoredContactsScreen(identityIdHex: String, navController: NavHostController) { + DashPayStubScaffold( + title = "Ignored", + rootTestTag = "dashpay.ignored.root", + navController = navController, + ) +} From acb8ce66b6080cb3798ab7fbeb1b185c24e3cf70 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 23:07:38 +0700 Subject: [PATCH 4/5] feat(kotlin-sdk): DashPay detail, payment, profile and list screens + parity (K3 slice C) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fill in the four stub screens and add the payment sheet, completing the DashPay tab port; delete the stub helper and rewrite PARITY for Views/DashPay/. - ContactDetailScreen: header (cached profile + DIP-15 account label), Send Dash (opens the payment sheet, disabled on broken channel), Room-driven payment history via the durable refreshDashPayPayments → observePayments path (sorted newest-first client-side), and alias/note editors + hide toggle via setContactInfo — surfacing the DEFERRED_UNTIL_TWO_CONTACTS / SKIPPED_WATCH_ONLY publish notices. - SendDashPayPaymentSheet (new): amount (decimal DASH → duffs) + balance check, sendPayment → txid (txidDisplayHex reversed-hex, added to the meta file), then always refreshDashPayPayments (the payment-durability invariant). No memo field (matches iOS); Balance.confirmed used as spendable. Hosted as a ModalBottomSheet from ContactDetail. - DashPayProfileScreen: read-only display + DIP-15 auto-accept QR (buildAutoAcceptQr rendered with the ZXing generateQrBitmap helper), plus an inline editor calling createOrUpdateProfile (doCreate when no profile exists). - IgnoredContactsScreen / HiddenContactsScreen: observeIgnoredSenders + unignoreContactSender / client-side hidden grouping + unhide (setContactInfo displayHidden=false, preserving alias/note). - Delete DashPayStub.kt (no stub remains). - androidTest/DashPayTabUITest.kt: ports the network-free launch-and-render flows from DashPayTabUITests.swift, adapted for the hub nav-section deviation. - PARITY.md: new Views/DashPay/ section (one row per view + deviations); FriendsView row retired; totals updated (ported 87 → 97). Gates: :app:compileDebugKotlin, :app:compileDebugAndroidTestKotlin, :sdk:testDebugUnitTest all pass. Co-Authored-By: Claude Fable 5 --- .../example/DashPayTabUITest.kt | 94 ++++ .../example/ui/dashpay/ContactDetailScreen.kt | 455 +++++++++++++++++- .../example/ui/dashpay/DashPayContactMeta.kt | 11 + .../ui/dashpay/DashPayProfileScreen.kt | 276 ++++++++++- .../example/ui/dashpay/DashPayStub.kt | 57 --- .../ui/dashpay/HiddenContactsScreen.kt | 176 ++++++- .../ui/dashpay/IgnoredContactsScreen.kt | 177 ++++++- .../ui/dashpay/SendDashPayPaymentSheet.kt | 203 ++++++++ packages/kotlin-sdk/PARITY.md | 40 +- 9 files changed, 1386 insertions(+), 103 deletions(-) create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/androidTest/java/org/dashfoundation/example/DashPayTabUITest.kt delete mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt create mode 100644 packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt 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 0000000000..29d2bea3ea --- /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/ui/dashpay/ContactDetailScreen.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/ContactDetailScreen.kt index 76200eec8c..15b505946b 100644 --- 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 @@ -1,23 +1,458 @@ 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.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.LocalContext +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 + payments — placeholder for the port of - * `ContactDetailView.swift`. Replaced by the K3 slice C implementation - * (header / Send Dash / payment history / local alias-note-hide settings); - * the signature and route ([org.dashfoundation.example.navigation.DashPayContactDetail]) - * are fixed here so ContactsScreen's row navigation compiles now. + * 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. */ -@androidx.compose.runtime.Composable +@OptIn(ExperimentalMaterial3Api::class) +@Composable fun ContactDetailScreen( identityIdHex: String, contactIdHex: String, navController: NavHostController, ) { - DashPayStubScaffold( - title = "Contact", - rootTestTag = "dashpay.detail.root", - navController = navController, + val container = LocalAppContainer.current + val appState = LocalAppState.current + val context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + 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 isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) + .collectAsStateWithLifecycle(false) + LaunchedEffect(isSyncing) { if (!isSyncing) refreshPayments() } + + 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) { + ModalBottomSheet(onDismissRequest = { showPaymentSheet = false }) { + SendDashPayPaymentSheet( + manager = manager!!, + walletId = walletId!!, + senderIdentityId = idBytes, + contactId = contactBytes, + contactDisplayName = displayName, + contactDpnsName = dpnsHint, + 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/DashPayContactMeta.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayContactMeta.kt index 3941ea1053..45400b3621 100644 --- 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 @@ -124,6 +124,17 @@ fun dashPayContactDisplayName( 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 ─────────────────────────────────────────────────────────────── /** 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 index 784c07cd6a..832d6a3481 100644 --- 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 @@ -1,20 +1,276 @@ package org.dashfoundation.example.ui.dashpay +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.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.launch +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 /** - * Read-only own-profile view + DIP-15 auto-accept QR — placeholder for the - * port of `DashPayProfileView.swift`. Replaced by the K3 slice C - * implementation; the signature + route - * ([org.dashfoundation.example.navigation.DashPayProfile]) are fixed here so - * the hub's "Your Profile" entry compiles now. + * 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) { - DashPayStubScaffold( - title = "Your Profile", - rootTestTag = "dashpay.profile.root", - navController = navController, - ) + 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) } + + 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 = remember(uri) { generateQrBitmap(uri) } + 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/DashPayStub.kt b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt deleted file mode 100644 index 3e294f7957..0000000000 --- a/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/DashPayStub.kt +++ /dev/null @@ -1,57 +0,0 @@ -package org.dashfoundation.example.ui.dashpay - -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.padding -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.MaterialTheme -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag -import androidx.navigation.NavHostController - -/** - * Shared placeholder scaffold for the DashPay screens still owned by K3 - * slice C (ContactDetail / Profile / Ignored / Hidden). Gives each a titled - * bar + back button + a root testTag so navigation from the hub / Contacts - * works end-to-end before those screens land. - */ -@OptIn(ExperimentalMaterial3Api::class) -@Composable -internal fun DashPayStubScaffold( - title: String, - rootTestTag: String, - navController: NavHostController, -) { - Scaffold( - topBar = { - TopAppBar( - title = { Text(title) }, - navigationIcon = { - IconButton(onClick = { navController.popBackStack() }) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") - } - }, - ) - }, - ) { padding -> - Box( - modifier = Modifier.fillMaxSize().padding(padding).testTag(rootTestTag), - contentAlignment = Alignment.Center, - ) { - Text( - "Coming in the next milestone.", - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - } -} 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 index 64f40bf97c..07cdccc5dd 100644 --- 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 @@ -1,20 +1,176 @@ 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.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.LocalContext +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 (reversible, cross-device) — placeholder - * for the port of `HiddenContactsView.swift`. Replaced by the K3 slice C - * implementation; the signature + route - * ([org.dashfoundation.example.navigation.DashPayHidden]) are fixed here so - * ContactsScreen's "Hidden contacts" link and the hub entry compile now. + * 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) { - DashPayStubScaffold( - title = "Hidden", - rootTestTag = "dashpay.hidden.root", - navController = navController, - ) + val container = LocalAppContainer.current + val appState = LocalAppState.current + val context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + 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()) } + + 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 index 3018463a63..a2c9de60e4 100644 --- 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 @@ -1,20 +1,177 @@ 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.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 (per-sender mute, reversible) — placeholder for the - * port of `IgnoredContactsView.swift`. Replaced by the K3 slice C - * implementation; the signature + route - * ([org.dashfoundation.example.navigation.DashPayIgnored]) are fixed here so - * the hub's "Ignored" entry compiles now. + * 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) { - DashPayStubScaffold( - title = "Ignored", - rootTestTag = "dashpay.ignored.root", - navController = navController, - ) + 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 } + } + + 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 0000000000..7000629c29 --- /dev/null +++ b/packages/kotlin-sdk/KotlinExampleApp/app/src/main/java/org/dashfoundation/example/ui/dashpay/SendDashPayPaymentSheet.kt @@ -0,0 +1,203 @@ +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.delay +import kotlinx.coroutines.launch +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?, + 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 + errorMessage = null + scope.launch { + try { + val txid = w.dashpay.sendPayment( + fromIdentityId = senderIdentityId, + toContactIdentityId = contactId, + amountDuffs = duffs, + coreSignerHandle = manager.mnemonicResolverHandle, + memo = null, + ) + successTxidHex = txid?.let { txidDisplayHex(it) } + onSent() + kickDashPaySync(scope, manager) + delay(1500) + onClose() + } catch (e: Exception) { + errorMessage = e.message ?: "Send failed" + } finally { + isSending = 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/PARITY.md b/packages/kotlin-sdk/PARITY.md index 061af19d7f..3bebbb4c90 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 + 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.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 From 78f36275aefa827474c344eca12b6e4961b5c487 Mon Sep 17 00:00:00 2001 From: Ivan Shumkov Date: Mon, 6 Jul 2026 23:37:01 +0700 Subject: [PATCH 5/5] fix(kotlin-sdk): fold K3 code-review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two-lens review (Compose concurrency + Swift parity) fixes on the DashPay tab. No behavior regressions; gates green. MUST-FIX - M1 payment sheet: wrap the send + post-broadcast bookkeeping (txid, onSent → durable refreshDashPayPayments) in withContext(NonCancellable), and block interactive dismissal of the host ModalBottomSheet while a send is in flight (sheetState confirmValueChange + guarded onDismissRequest via a new onSendingChange callback). Prevents a swipe-dismiss-mid-send from losing the confirmation/refresh after the (uncancellable) broadcast → double-payment risk. SHOULD-FIX - S1: register a NativeCleaner GC backstop on ContactRequestRef / EstablishedContactRef (matching Sdk.kt) so a cancellation-dropped ref can't leak its native handle for the process lifetime. - S2: drop ContactRequestsScreen's blanket LaunchedEffect(isSyncing) overlay wipe; the rows-driven prune already scopes removal to the action's own change, so an unrelated sweep no longer flashes accepted/ignored rows back. - S3: make DashPayContactMetaStore a single shared instance on AppContainer; the five screens now read it, so a write's version bump invalidates every reader (a per-screen instance only recomposed its own creator). - S4: guard the unlock banner with a local isUnlocking (disable the button while in flight) so a double-tap can't stack concurrent unlockWalletFromKeystore. - Parity S1: Dashpay.buildAutoAcceptQr username String? → non-optional String (Swift parity; the FFI check_ptr!-rejects null so the default was a latent throw); corrected the DashpayNative.kt + rs-unified-sdk-jni JNI doc claims. NOTES folded in: N1 (unconditional composable calls in UnlockBanner), N2 (remember the fallback dashPaySyncIsSyncing flow), N6 (prune Ignored/Hidden overlays against Room like Requests), N7 (encode the profile QR off the main thread via produceState). PARITY.md N7: added dashpay.profileHeader.setup to the not-reproduced tags + noted the AlertDialog alias/note editors. Gates: cargo check -p rs-unified-sdk-jni + :app:compileDebugKotlin :app:compileDebugAndroidTestKotlin :sdk:compileDebugKotlin :sdk:testDebugUnitTest all pass. Co-Authored-By: Claude Fable 5 --- .../dashfoundation/example/di/AppContainer.kt | 10 +++++ .../example/ui/dashpay/AddContactScreen.kt | 4 +- .../example/ui/dashpay/ContactDetailScreen.kt | 21 +++++++--- .../ui/dashpay/ContactRequestsScreen.kt | 21 ++++------ .../example/ui/dashpay/ContactsScreen.kt | 4 +- .../ui/dashpay/DashPayProfileScreen.kt | 12 +++++- .../example/ui/dashpay/DashPayTabScreen.kt | 40 ++++++++++++++----- .../ui/dashpay/HiddenContactsScreen.kt | 14 +++++-- .../ui/dashpay/IgnoredContactsScreen.kt | 9 +++++ .../ui/dashpay/SendDashPayPaymentSheet.kt | 34 +++++++++++----- packages/kotlin-sdk/PARITY.md | 4 +- .../dashsdk/ffi/DashpayNative.kt | 6 ++- .../dashfoundation/dashsdk/tokens/Dashpay.kt | 36 +++++++++++++---- packages/rs-unified-sdk-jni/src/dashpay.rs | 5 ++- 14 files changed, 160 insertions(+), 60 deletions(-) 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 c213bf3c58..3497cf0c8f 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; 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 index ccf9e290ea..7a92748c67 100644 --- 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 @@ -34,7 +34,6 @@ 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.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle @@ -76,13 +75,12 @@ private sealed interface SearchState { fun AddContactScreen(identityIdHex: String, navController: NavHostController) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val identity by remember(idBytes) { container.database.identityDao().observeByIdentityId(idBytes) 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 index 15b505946b..9d51aadd27 100644 --- 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 @@ -23,6 +23,7 @@ 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 @@ -37,7 +38,6 @@ 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.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp @@ -74,14 +74,13 @@ fun ContactDetailScreen( ) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val metaVersion by metaStore.version.collectAsStateWithLifecycle() val identity by remember(idBytes) { @@ -180,10 +179,12 @@ fun ContactDetailScreen( } LaunchedEffect(Unit) { refreshPayments() } - val isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) - .collectAsStateWithLifecycle(false) + 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( @@ -332,7 +333,14 @@ fun ContactDetailScreen( } if (showPaymentSheet && manager != null && walletId != null) { - ModalBottomSheet(onDismissRequest = { showPaymentSheet = false }) { + // 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!!, @@ -340,6 +348,7 @@ fun ContactDetailScreen( contactId = contactBytes, contactDisplayName = displayName, contactDpnsName = dpnsHint, + onSendingChange = { paymentSending = it }, onSent = { refreshPayments() }, onClose = { showPaymentSheet = false }, ) 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 index ba1f2b9743..b6057c802e 100644 --- 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 @@ -33,13 +33,11 @@ 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.LocalContext 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.example.di.LocalAppContainer import org.dashfoundation.example.di.LocalAppState @@ -64,13 +62,12 @@ import org.dashfoundation.example.util.toHex fun ContactRequestsScreen(identityIdHex: String, navController: NavHostController) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val metaVersion by metaStore.version.collectAsStateWithLifecycle() val identity by remember(idBytes) { @@ -96,11 +93,12 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle var rowErrors by remember { mutableStateOf(emptyMap()) } var isRefreshing by remember { mutableStateOf(false) } - val isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) - .collectAsStateWithLifecycle(false) - - // Prune the optimistic-removal overlay once the query reflects the change - // (accept promotes to established, ignore deletes the row). + // 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 -> @@ -108,11 +106,6 @@ fun ContactRequestsScreen(identityIdHex: String, navController: NavHostControlle group.any { !it.isOutgoing } && group.none { it.isOutgoing } }.toSet() } - // Fallback clear: after the next completed sweep, expire whatever the - // query still hasn't reflected so no row stays hidden forever. - LaunchedEffect(isSyncing) { - if (!isSyncing) removedOverlayIds = emptySet() - } fun displayNameFor(contactId: ByteArray): String = dashPayContactDisplayName( contactId = contactId, 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 index 48138fc354..3cc84a9a4f 100644 --- 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 @@ -32,7 +32,6 @@ 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.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp @@ -62,13 +61,12 @@ import org.dashfoundation.example.util.toHex fun ContactsScreen(identityIdHex: String, navController: NavHostController) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val metaVersion by metaStore.version.collectAsStateWithLifecycle() val rows by remember(idBytes) { 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 index 832d6a3481..2f81559246 100644 --- 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 @@ -1,5 +1,6 @@ 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 @@ -24,6 +25,7 @@ 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 @@ -37,7 +39,9 @@ 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 @@ -73,6 +77,12 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController 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("") } @@ -226,7 +236,7 @@ fun DashPayProfileScreen(identityIdHex: String, navController: NavHostController val uri = qrUri when { uri != null -> { - val bitmap = remember(uri) { generateQrBitmap(uri) } + val bitmap = qrBitmap Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, 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 index 5b9040fdd8..fac43a6e24 100644 --- 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 @@ -43,6 +43,7 @@ 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 @@ -245,8 +246,8 @@ private fun BalanceRow( // 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 isSyncing by (manager?.dashPaySyncIsSyncing ?: MutableStateFlow(false)) - .collectAsStateWithLifecycle(false) + 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) { @@ -273,12 +274,20 @@ private fun UnlockBanner( managed: ManagedPlatformWallet?, onError: (String) -> Unit, ) { - val m = manager ?: return - val statusMap by m.dashPayUnlockStatus.collectAsStateWithLifecycle() - val status = walletIdHex?.let { statusMap[it] } ?: return + // 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 - val scope = rememberCoroutineScope() when { status.seedMismatch -> BannerRow( @@ -300,11 +309,16 @@ private fun UnlockBanner( 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 = m.unlockWalletFromKeystore(managed) + val unlocked = manager.unlockWalletFromKeystore(managed) if (!unlocked) { onError( "This wallet is watch-only on this device (no mnemonic in " + @@ -313,6 +327,8 @@ private fun UnlockBanner( } } catch (e: Exception) { onError(e.message ?: "Unlock failed") + } finally { + isUnlocking = false } } } @@ -325,7 +341,13 @@ private fun UnlockBanner( } @Composable -private fun BannerRow(icon: ImageVector, tint: androidx.compose.ui.graphics.Color, text: String, action: (() -> Unit)?) { +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), @@ -334,7 +356,7 @@ private fun BannerRow(icon: ImageVector, tint: androidx.compose.ui.graphics.Colo Icon(icon, contentDescription = null, tint = tint) Text(text, style = MaterialTheme.typography.bodySmall, modifier = Modifier.weight(1f)) if (action != null) { - Button(onClick = action) { Text("Unlock") } + Button(onClick = action, enabled = actionEnabled) { Text("Unlock") } } } } 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 index 07cdccc5dd..64621184fe 100644 --- 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 @@ -15,13 +15,13 @@ 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.platform.LocalContext import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation.NavHostController @@ -43,13 +43,12 @@ import org.dashfoundation.example.util.toHex fun HiddenContactsScreen(ownerIdentityIdHex: String, navController: NavHostController) { val container = LocalAppContainer.current val appState = LocalAppState.current - val context = LocalContext.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 = remember { DashPayContactMetaStore(context) } + val metaStore = container.dashPayContactMetaStore val metaVersion by metaStore.version.collectAsStateWithLifecycle() val identity by remember(ownerBytes) { @@ -70,6 +69,15 @@ fun HiddenContactsScreen(ownerIdentityIdHex: String, navController: NavHostContr 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) -> 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 index a2c9de60e4..fa7cf1a6a1 100644 --- 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 @@ -22,6 +22,7 @@ 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 @@ -76,6 +77,14 @@ fun IgnoredContactsScreen(identityIdHex: String, navController: NavHostControlle .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() 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 index 7000629c29..b2268fce6a 100644 --- 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 @@ -24,8 +24,11 @@ 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 @@ -49,6 +52,7 @@ fun SendDashPayPaymentSheet( contactId: ByteArray, contactDisplayName: String, contactDpnsName: String?, + onSendingChange: (Boolean) -> Unit, onSent: () -> Unit, onClose: () -> Unit, ) { @@ -81,25 +85,37 @@ fun SendDashPayPaymentSheet( 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 { - val txid = w.dashpay.sendPayment( - fromIdentityId = senderIdentityId, - toContactIdentityId = contactId, - amountDuffs = duffs, - coreSignerHandle = manager.mnemonicResolverHandle, - memo = null, - ) - successTxidHex = txid?.let { txidDisplayHex(it) } - onSent() + // 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) } } } diff --git a/packages/kotlin-sdk/PARITY.md b/packages/kotlin-sdk/PARITY.md index 3bebbb4c90..022366eab4 100644 --- a/packages/kotlin-sdk/PARITY.md +++ b/packages/kotlin-sdk/PARITY.md @@ -94,7 +94,7 @@ verbatim. Documented deviations from iOS are listed after the table. | 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 + hide toggle via `setContactInfo` **surfacing the DIP-15 deferred / watch-only publish notices** | +| 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 | @@ -103,7 +103,7 @@ verbatim. Documented deviations from iOS are listed after the table. **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.usernamePrompt` are not reproduced; Ignored + Hidden are surfaced as hub rows (iOS gated Hidden behind a Contacts link, which the Kotlin ContactsScreen also keeps). +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. 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 1bc1bb1a3b..1ac52ed291 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 @@ -179,13 +179,15 @@ internal object DashpayNative { /** * Build the owner's DIP-15 auto-accept QR payload * (`dash:?du=…&dapk=…`) for [identityId], keying the proof through - * [coreSignerHandle]. [username] is a display hint (nullable). + * [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?, + username: String, coreSignerHandle: Long, ): String? 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 d140cd1c55..5bf3b9c2e2 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 /** @@ -325,10 +326,15 @@ class Dashpay internal constructor(private val walletHandle: Long) { * 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? = null, + username: String, coreSignerHandle: Long, ): String? = withContext(Dispatchers.IO) { mapNativeErrors { @@ -480,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 ce64501ca9..15d4a55a66 100644 --- a/packages/rs-unified-sdk-jni/src/dashpay.rs +++ b/packages/rs-unified-sdk-jni/src/dashpay.rs @@ -813,7 +813,10 @@ fn opt_jstring_to_cstring(env: &mut JNIEnv, s: &JString) -> Option