Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ class AppSmokeTest {
listOf(
"rootTab.wallets",
"rootTab.identities",
"rootTab.contracts",
"rootTab.dashpay",
"rootTab.settings",
"rootTab.sync",
).forEach { tag ->
Expand Down
Original file line number Diff line number Diff line change
@@ -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<MainActivity>()

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"))
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -183,6 +193,7 @@ class AppContainer(private val context: Context) {
if (shieldedService.isAvailable) {
manager.stopShieldedSync()
}
manager.stopDashPaySync()
} catch (e: Exception) {
android.util.Log.w(TAG, "Failed to stop sync coordinators", e)
}
Expand All @@ -209,6 +220,13 @@ class AppContainer(private val context: Context) {
if (shieldedService.isAvailable && !manager.isShieldedSyncRunning()) {
manager.startShieldedSync()
}
// DashPay contact/profile/payment sync — the load-bearing
// prerequisite for the DashPay tab (← iOS
// rebindWalletScopedServices starting it alongside the
// address/shielded loops on the wallet-present branch).
if (!manager.isDashPaySyncRunning()) {
manager.startDashPaySync()
}
} catch (e: Exception) {
android.util.Log.e(TAG, "Failed to bind wallet-scoped services", e)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,15 @@ import org.dashfoundation.example.ui.diagnostics.WalletMemoryExplorerScreen
import org.dashfoundation.example.ui.identity.AddIdentityKeyScreen
import org.dashfoundation.example.ui.identity.ContestDetailScreen
import org.dashfoundation.example.ui.identity.CreateIdentityScreen
import org.dashfoundation.example.ui.dashpay.AddContactScreen
import org.dashfoundation.example.ui.dashpay.ContactDetailScreen
import org.dashfoundation.example.ui.dashpay.ContactRequestsScreen
import org.dashfoundation.example.ui.dashpay.ContactsScreen
import org.dashfoundation.example.ui.dashpay.DashPayProfileScreen
import org.dashfoundation.example.ui.dashpay.DashPayTabScreen
import org.dashfoundation.example.ui.dashpay.HiddenContactsScreen
import org.dashfoundation.example.ui.dashpay.IgnoredContactsScreen
import org.dashfoundation.example.ui.identity.DpnsTestScreen
import org.dashfoundation.example.ui.identity.FriendsScreen
import org.dashfoundation.example.ui.identity.IdentitiesHomeScreen
import org.dashfoundation.example.ui.identity.IdentityDetailScreen
import org.dashfoundation.example.ui.identity.KeyDetailScreen
Expand Down Expand Up @@ -164,11 +171,6 @@ fun AppNavHost(
SelectMainNameScreen(route.identityIdHex, navController)
}

composable<Friends> { entry ->
val route = entry.toRoute<Friends>()
FriendsScreen(route.identityIdHex, navController)
}

composable<KeysList> { entry ->
val route = entry.toRoute<KeysList>()
KeysListScreen(route.identityIdHex, navController)
Expand Down Expand Up @@ -366,6 +368,39 @@ fun AppNavHost(
CoSignProposalScreen(entry.toRoute<CoSignProposal>(), navController)
}

// ── DashPay graph ──────────────────────────────────────────────

composable<DashPayHome> { DashPayTabScreen(navController) }

composable<DashPayContacts> { entry ->
ContactsScreen(entry.toRoute<DashPayContacts>().identityIdHex, navController)
}

composable<DashPayRequests> { entry ->
ContactRequestsScreen(entry.toRoute<DashPayRequests>().identityIdHex, navController)
}

composable<DashPayAddContact> { entry ->
AddContactScreen(entry.toRoute<DashPayAddContact>().identityIdHex, navController)
}

composable<DashPayContactDetail> { entry ->
val route = entry.toRoute<DashPayContactDetail>()
ContactDetailScreen(route.identityIdHex, route.contactIdHex, navController)
}

composable<DashPayProfile> { entry ->
DashPayProfileScreen(entry.toRoute<DashPayProfile>().identityIdHex, navController)
}

composable<DashPayIgnored> { entry ->
IgnoredContactsScreen(entry.toRoute<DashPayIgnored>().identityIdHex, navController)
}

composable<DashPayHidden> { entry ->
HiddenContactsScreen(entry.toRoute<DashPayHidden>().ownerIdentityIdHex, navController)
}

// ── Diagnostics graph ──────────────────────────────────────────

composable<AddressQueries> { AddressQueriesScreen(navController) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ import kotlinx.serialization.Serializable

@Serializable object ContractsHome

@Serializable object DashPayHome

@Serializable object SettingsHome

// ── Settings graph ─────────────────────────────────────────────────────
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -174,6 +173,32 @@ import kotlinx.serialization.Serializable
/** Shielded activity timeline for a wallet (← `ShieldedActivityView.swift`). */
@Serializable data class ShieldedActivity(val walletIdHex: String)

// ── DashPay graph (hosted under the DashPay tab, ← the DashPay/ views) ──

/** Established-contacts list for an identity (← `ContactsView.swift`). */
@Serializable data class DashPayContacts(val identityIdHex: String)

/** Incoming + outgoing contact requests (← `ContactRequestsView.swift`). */
@Serializable data class DashPayRequests(val identityIdHex: String)

/** Add-a-contact form (DPNS search / paste id / QR, ← `AddContactView.swift`). */
@Serializable data class DashPayAddContact(val identityIdHex: String)

/** One contact's detail + payments (← `ContactDetailView.swift`). */
@Serializable data class DashPayContactDetail(
val identityIdHex: String,
val contactIdHex: String,
)

/** Read-only own-profile sheet (← `DashPayProfileView.swift`). */
@Serializable data class DashPayProfile(val identityIdHex: String)

/** Ignored-senders list (← `IgnoredContactsView.swift`). */
@Serializable data class DashPayIgnored(val identityIdHex: String)

/** Hidden established-contacts list (← `HiddenContactsView.swift`). */
@Serializable data class DashPayHidden(val ownerIdentityIdHex: String)

// ── Contracts graph ────────────────────────────────────────────────────

/** Fetch-a-contract screen (← `LocalDataContractsView.swift`). */
Expand Down Expand Up @@ -345,6 +370,6 @@ enum class RootTab(
SYNC(SyncHome, SyncHome::class, "rootTab.sync"),
WALLETS(WalletsHome, WalletsHome::class, "rootTab.wallets"),
IDENTITIES(IdentitiesHome, IdentitiesHome::class, "rootTab.identities"),
CONTRACTS(ContractsHome, ContractsHome::class, "rootTab.contracts"),
DASHPAY(DashPayHome, DashPayHome::class, "rootTab.dashpay"),
SETTINGS(SettingsHome, SettingsHome::class, "rootTab.settings"),
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
}

Expand All @@ -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"
}
Loading
Loading