diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 215106974..8dd4cc9a9 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -794,4 +794,7 @@ From Your Contacts Add Contact + Are You Sure? + Anyone you sent the link to won\'t be able to collect the cash + \ No newline at end of file diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index ce533dd75..60533848e 100644 --- a/apps/flipcash/shared/session/build.gradle.kts +++ b/apps/flipcash/shared/session/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(testFixtures(project(":libs:coroutines"))) testImplementation(testFixtures(project(":ui:resources"))) implementation(project(":apps:flipcash:shared:chat")) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 59abb44c4..32ab36eec 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -20,19 +20,31 @@ sealed interface BillDeterminationResult { data object Grabbed : BillDeterminationResult, ActedUpon data object PutInWallet : BillDeterminationResult, ActedUpon -interface SessionController { - val state: StateFlow +interface BillOperations { val billState: StateFlow - fun onAppInForeground() - fun onAppInBackground() - fun onCameraScanning(scanning: Boolean) fun showBill(bill: Bill) fun dismissBill(action: BillDeterminationResult) +} + +interface CodeScanOperations { + fun onCameraScanning(scanning: Boolean) fun onCodeScan(code: ScannableKikCode) +} + +interface CashLinkOperations { fun openCashLink(cashLink: String?) +} + +interface DepositOperations { fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)? = null) } +interface SessionController : BillOperations, CodeScanOperations, CashLinkOperations, DepositOperations { + val state: StateFlow + fun onAppInForeground() + fun onAppInBackground() +} + data class SessionState( val vibrateOnScan: Boolean = false, val hasGiveableBalance: Boolean = false, diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 223a08156..0621da434 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -2,161 +2,157 @@ package com.flipcash.app.session.internal import com.flipcash.app.activityfeed.ActivityFeedCoordinator import com.flipcash.app.activityfeed.ActivityFeedUpdater -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.billing.BillingClient import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.shared.chat.ChatCoordinator -import com.flipcash.app.core.AppRoute -import com.flipcash.app.core.bill.Bill -import com.flipcash.app.core.bill.BillState -import com.flipcash.app.core.bill.PaymentValuation -import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.internal.bill.BillController -import com.flipcash.app.core.internal.errors.showNetworkError import com.flipcash.app.core.internal.updater.ProfileUpdater -import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController -import com.flipcash.app.payments.PurchaseMethodController -import com.flipcash.app.session.BillDeterminationResult -import com.flipcash.app.session.Grabbed +import com.flipcash.app.session.BillOperations +import com.flipcash.app.session.CashLinkOperations +import com.flipcash.app.session.CodeScanOperations +import com.flipcash.app.session.DepositOperations import com.flipcash.app.session.PutInWallet import com.flipcash.app.session.SessionController import com.flipcash.app.session.SessionState +import com.flipcash.app.session.internal.delegates.BillPresentationDelegate +import com.flipcash.app.session.internal.delegates.CashLinkDelegate +import com.flipcash.app.session.internal.delegates.CodeScanDelegate +import com.flipcash.app.session.internal.delegates.DepositDelegate +import com.flipcash.app.session.internal.delegates.GiftCardSharingDelegate import com.flipcash.app.session.internal.toast.SessionToastController -import com.flipcash.app.shareable.ShareConfirmationResult -import com.flipcash.app.shareable.ShareResult import com.flipcash.app.shareable.ShareSheetController -import com.flipcash.app.shareable.Shareable -import com.flipcash.app.shareable.ShareableConfirmationController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.TokenUpdater -import com.flipcash.app.tokens.UsdcDepositSweep -import com.flipcash.core.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.AccountController import com.flipcash.services.controllers.SettingsController import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager -import com.getcode.manager.BottomBarAction import com.getcode.manager.BottomBarManager -import com.getcode.opencode.controllers.TransactionController -import com.getcode.opencode.internal.manager.VerifiedState -import com.getcode.opencode.internal.transactors.ReceiveGiftTransactorError -import com.getcode.opencode.model.accounts.AccountCluster -import com.getcode.opencode.model.accounts.GiftCardAccount -import com.getcode.opencode.model.core.OpenCodePayload -import com.getcode.opencode.model.core.PayloadKind -import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.opencode.model.financial.Token -import com.getcode.opencode.model.financial.sum import com.getcode.ui.core.RestrictionType -import com.getcode.util.permissions.PermissionResult -import com.getcode.util.resources.ResourceHelper -import com.getcode.util.vibration.Vibrator -import com.getcode.utils.ErrorUtils import com.getcode.utils.TraceType -import com.getcode.utils.base58 -import com.getcode.utils.hexEncodedString import com.getcode.utils.network.NetworkConnectivityListener import com.getcode.utils.trace -import com.kik.kikx.models.ScannableKikCode import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.suspendCancellableCoroutine -import java.util.concurrent.atomic.AtomicBoolean import javax.inject.Inject -import kotlin.coroutines.resume -import kotlin.time.Clock -import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.seconds /** - * This class is the central orchestrator for the application's session, managing the user's - * interaction flow, data synchronization, and state transitions. It integrates various - * controllers and services to provide a cohesive user experience. + * Thin orchestration shell that implements [SessionController] by composing five + * focused delegates via Kotlin `by` interface delegation: + * + * | Delegate | Interface | Responsibility | + * |----------|-----------|----------------| + * | [com.flipcash.app.session.internal.delegates.BillPresentationDelegate] | [BillOperations] | Creating, presenting, and dismissing cash bills | + * | [com.flipcash.app.session.internal.delegates.CodeScanDelegate] | [CodeScanOperations] | QR/Kik-code scanning and grab attempts | + * | [com.flipcash.app.session.internal.delegates.CashLinkDelegate] | [CashLinkOperations] | Cash-link claiming | + * | [com.flipcash.app.session.internal.delegates.DepositDelegate] | [DepositOperations] | Deposit options and USDC sweep | + * | [com.flipcash.app.session.internal.delegates.GiftCardSharingDelegate] | *(internal)* | "Send as Link" gift-card funding + share | * - * Key Responsibilities: - * - **State Management**: Maintains and exposes the overall session state ([SessionState]) and bill state - * ([BillState]), reacting to changes in authentication, network connectivity, and app lifecycle. - * - **Lifecycle Events**: Handles `onAppInForeground` and `onAppInBackground` events to start/stop - * data polling (balances, activity feed), connect/disconnect services, and manage UI state. - * - **Transaction Flow**: - * - **Scanning**: Processes scanned QR codes ([onCodeScan]) to initiate cash grabs. - * - **Cash Links**: Manages the creation ([shareGiftCard]) and claiming ([openCashLink]) of cash links. - * - **Bill Presentation**: Coordinates with [BillController] to display bills for sending or - * receiving cash ([showBill], [presentBillToUser]). - * - **Data Synchronization**: Uses updaters ([TokenUpdater], [ActivityFeedUpdater], [ProfileUpdater]) - * to keep local data fresh. - * - **User Interaction**: Manages UI feedback like toasts ([ToastController]), vibrations ([Vibrator]), - * and bottom bar messages ([BottomBarManager]). It also handles interactions with the native - * share sheet ([ShareSheetController]). - * - **Feature & Settings Integration**: Responds to changes in feature flags and app settings, - * such as `VibrateOnScan` and `CameraStartByDefault`. + * **What lives here (and why):** + * - **Event routing** — each delegate exposes a `Flow` (backed by a `Channel`); the `init` block + * collects all five and dispatches cross-delegate calls (e.g. scan-delegate's + * `BillReady` → `showBill`, bill-delegate's `SendAsLinkRequested` → + * `giftCardDelegate.shareGiftCard`). All cross-delegate wiring is visible in one + * place. + * - **Lifecycle methods** — [onAppInForeground] / [onAppInBackground] are inherently + * cross-cutting (polling, billing, share-sheet, feed refresh) and stay on the shell. + * - **Flow observers** — auth-state transitions, feature flags, network reconnects, + * and balance/token observations that update [SessionStateHolder]. + * + * Delegates are fully self-contained after Hilt construction — no `lateinit`, + * no `initialize()` calls, no post-construction wiring. */ +@OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class) class RealSessionController @Inject constructor( + private val billDelegate: BillPresentationDelegate, + private val scanDelegate: CodeScanDelegate, + private val cashLinkDelegate: CashLinkDelegate, + private val depositDelegate: DepositDelegate, + private val giftCardDelegate: GiftCardSharingDelegate, + private val stateHolder: SessionStateHolder, private val billController: BillController, private val userManager: UserManager, private val accountController: AccountController, private val settingsController: SettingsController, private val feedCoordinator: ActivityFeedCoordinator, - private val transactionController: TransactionController, - private val networkObserver: NetworkConnectivityListener, - private val resources: ResourceHelper, - private val vibrator: Vibrator, private val tokenUpdater: TokenUpdater, private val activityFeedUpdater: ActivityFeedUpdater, private val profileUpdater: ProfileUpdater, private val shareSheetController: ShareSheetController, - private val shareConfirmationController: ShareableConfirmationController, private val toastController: SessionToastController, private val billingClient: BillingClient, private val tokenCoordinator: TokenCoordinator, private val contactCoordinator: ContactCoordinator, private val chatCoordinator: ChatCoordinator, - private val featureFlagController: FeatureFlagController, - private val purchaseMethodController: PurchaseMethodController, - private val analytics: FlipcashAnalyticsService, - private val usdcSweep: UsdcDepositSweep, + networkObserver: NetworkConnectivityListener, + featureFlagController: FeatureFlagController, appSettingsCoordinator: AppSettingsCoordinator, - private val dispatchers: DispatcherProvider, -) : SessionController { + dispatchers: DispatcherProvider, +) : SessionController, BillOperations by billDelegate, + CodeScanOperations by scanDelegate, + CashLinkOperations by cashLinkDelegate, + DepositOperations by depositDelegate { private val scope = CoroutineScope(dispatchers.IO + SupervisorJob()) - private val _state = MutableStateFlow(SessionState()) override val state: StateFlow - get() = _state.asStateFlow() + get() = stateHolder.state + + init { + // Collect delegate events and dispatch cross-delegate calls + billDelegate.events + .onEach { event -> + when (event) { + is BillPresentationDelegate.Event.SendAsLinkRequested -> + giftCardDelegate.shareGiftCard(event.bill, event.owner) + is BillPresentationDelegate.Event.RefreshFeed -> + bringActivityFeedCurrent() + } + }.launchIn(scope) - override val billState: StateFlow - get() = billController.state + scanDelegate.events + .onEach { event -> + when (event) { + is CodeScanDelegate.Event.BillReady -> showBill(event.bill) + is CodeScanDelegate.Event.RefreshFeed -> bringActivityFeedCurrent() + is CodeScanDelegate.Event.CheckPendingFeed -> checkPendingItemsInFeed() + } + }.launchIn(scope) - private val scannedRendezvous = mutableMapOf() + cashLinkDelegate.events + .onEach { event -> + when (event) { + is CashLinkDelegate.Event.BillReady -> showBill(event.bill) + is CashLinkDelegate.Event.RefreshFeed -> bringActivityFeedCurrent() + is CashLinkDelegate.Event.CheckPendingFeed -> checkPendingItemsInFeed() + } + }.launchIn(scope) - private val giftCardFundingInProgress = AtomicBoolean(false) - private val giftCardClaimInProgress = MutableStateFlow(null) + giftCardDelegate.events + .onEach { event -> + when (event) { + is GiftCardSharingDelegate.Event.DismissBill -> dismissBill(event.action) + is GiftCardSharingDelegate.Event.RestartBillGrab -> + billDelegate.awaitBillGrab(event.bill, event.owner) + is GiftCardSharingDelegate.Event.RefreshFeed -> bringActivityFeedCurrent() + } + }.launchIn(scope) - init { // handle auth state transitions: cleanup on logout, start polling on login userManager.state .map { it.authState } @@ -165,10 +161,10 @@ class RealSessionController @Inject constructor( when { authState is AuthState.LoggedOut -> { stopPolling() - cancelUpdates() + depositDelegate.cancelSweep() scope.launch { contactCoordinator.reset() } scope.launch { chatCoordinator.reset() } - _state.update { SessionState() } + stateHolder.reset() } authState is AuthState.Ready -> { @@ -183,7 +179,7 @@ class RealSessionController @Inject constructor( userManager.state .map { it.isTimelockUnlocked } - .onEach { _state.update { it.copy(restrictionType = RestrictionType.TIMELOCK_UNLOCKED) } } + .onEach { stateHolder.update { it.copy(restrictionType = RestrictionType.TIMELOCK_UNLOCKED) } } .launchIn(scope) userManager.state @@ -200,38 +196,34 @@ class RealSessionController @Inject constructor( .distinctUntilChanged() .flatMapLatest { chatCoordinator.observeUnreadConversations() } .distinctUntilChanged() - .onEach { count -> _state.update { it.copy(notificationUnreadCount = count) } } + .onEach { count -> stateHolder.update { it.copy(notificationUnreadCount = count) } } .launchIn(scope) appSettingsCoordinator .observeValue(AppSettingValue.CameraStartByDefault) - .onEach { autoStart -> _state.update { it.copy(autoStartCamera = autoStart) } } - .launchIn(scope) - - featureFlagController.observe(FeatureFlag.DepositFirstUX) - .onEach { enabled -> _state.update { it.copy(depositFirstUx = enabled) } } + .onEach { autoStart -> stateHolder.update { it.copy(autoStartCamera = autoStart) } } .launchIn(scope) featureFlagController.observe(FeatureFlag.VibrateOnScan) - .onEach { enabled -> _state.update { it.copy(vibrateOnScan = enabled) } } + .onEach { enabled -> stateHolder.update { it.copy(vibrateOnScan = enabled) } } .launchIn(scope) tokenCoordinator.tokenBalances .map { tokenCoordinator.hasGiveableBalance() } .distinctUntilChanged() - .onEach { hasBalance -> _state.update { it.copy(hasGiveableBalance = hasBalance) } } + .onEach { hasBalance -> stateHolder.update { it.copy(hasGiveableBalance = hasBalance) } } .launchIn(scope) tokenCoordinator.tokens .onEach { tokens -> - _state.update { it.copy(tokens = tokens) } + stateHolder.update { it.copy(tokens = tokens) } }.launchIn(scope) combine( featureFlagController.observe(FeatureFlag.PhoneNumberSend), userManager.state.map { it.flags?.enablePhoneNumberSend == true } ) { beta, server -> beta || server } - .onEach { enabled -> _state.update { it.copy(isPhoneNumberSendEnabled = enabled) } } + .onEach { enabled -> stateHolder.update { it.copy(isPhoneNumberSendEnabled = enabled) } } .launchIn(scope) // Retry updateUserFlags when network is restored @@ -242,22 +234,11 @@ class RealSessionController @Inject constructor( .onEach { if (userManager.authState.isAtLeastRegistered) { updateUserFlags() - swapUsdcIfNeeded() + depositDelegate.sweepIfNeeded() } }.launchIn(scope) } - /** - * Called when the app enters the foreground. - * - * This function performs several actions to ensure the app is up-to-date and ready for user interaction: - * 1. Starts polling for updates (e.g., balance, exchange rates, activity feed). - * 2. Updates user flags. - * 4. Checks for pending items in the activity feed. - * 5. Brings the activity feed to the current state. - * 6. Checks for any pending share actions via the share sheet. - * 7. If the user is registered, connects to the billing client. - */ override fun onAppInForeground() { trace( tag = "Session", @@ -265,7 +246,7 @@ class RealSessionController @Inject constructor( type = TraceType.Process, ) startPolling() - swapUsdcIfNeeded() + depositDelegate.sweepIfNeeded() updateUserFlags() linkForPaymentIfNeeded() updateSettings() @@ -277,19 +258,9 @@ class RealSessionController @Inject constructor( } } - /** - * Called when the application enters the background. - * This function stops polling for updates, disconnects the billing client, - * and clears any pending UI elements related to bills or sharing if certain conditions are met. - * - * Specifically, it clears the bottom bar, cancels any pending bill grab actions, and cancels - * any ongoing send operations if: - * - The share sheet is not currently checking for a share action, OR - * - There is an active bill, and it has not yet been received. - */ override fun onAppInBackground() { stopPolling() - cancelUpdates() + depositDelegate.cancelSweep() billingClient.disconnect() toastController.clear() @@ -310,23 +281,12 @@ class RealSessionController @Inject constructor( } } - private fun swapUsdcIfNeeded() { - val owner = userManager.accountCluster ?: return - if (userManager.authState.canAccessAuthenticatedApis) { - usdcSweep.execute(owner) - } - } - private fun stopPolling() { tokenUpdater.stop() activityFeedUpdater.stop() profileUpdater.stop() } - private fun cancelUpdates() { - usdcSweep.cancel() - } - private fun updateUserFlags() { if (userManager.authState.isAtLeastRegistered) { scope.launch { @@ -335,13 +295,10 @@ class RealSessionController @Inject constructor( userManager.set(flags) val currentState = userManager.authState when { - // Don't promote during onboarding — the permissions - // completion flow sets Ready when navigating to Scanner. flags.isRegistered && !currentState.canAccessAuthenticatedApis && currentState !is AuthState.Onboarding -> { userManager.set(authState = AuthState.Ready) } - // Reconcile resume point with freshly-loaded flags. currentState is AuthState.Onboarding -> { val corrected = when (currentState.resumePoint) { AuthState.ResumePoint.PostAccessKey -> @@ -395,644 +352,4 @@ class RealSessionController @Inject constructor( } } } - - override fun onCameraScanning(scanning: Boolean) { - _state.update { it.copy(isCameraUp = scanning) } - } - - override fun showBill(bill: Bill) { - if (bill.amount.nativeAmount.decimalValue == 0.0) return - val owner = userManager.accountCluster ?: return - - if (!networkObserver.isConnected) { - return ErrorUtils.showNetworkError(resources) - } - - // Don't show action buttons for airdrop - when (bill) { - is Bill.Cash -> { - when (bill.kind) { - Bill.Kind.airdrop -> { - // Don't show action buttons for airdrop - billController.update { - it.copy( - primaryAction = null, - secondaryAction = null, - ) - } - } - - Bill.Kind.cash -> { - if (bill.didReceive) { - // Don't show action buttons for received cash - billController.update { - it.copy( - primaryAction = null, - secondaryAction = null, - ) - } - } else { - billController.update { - it.copy( - primaryAction = BillState.Action.SendAsLink( - action = { - billController.cancelAwaitForGrab() - - shareGiftCard( - amount = bill.amount, - token = bill.token, - owner = owner, - verifiedState = bill.verifiedState!! - ) { - trace( - tag = "Session", - message = "Cash link not sent. Restarting awaiting grab", - type = TraceType.User, - ) - // Use the state-enriched bill which carries the - // nonce from the first presentation, so the - // restarted give reuses the same rendezvous. - val currentBill = - billController.state.value.bill ?: bill - awaitBillGrab(currentBill, owner) - } - } - ), - // Allow cancelling pending outgoing cash bills - secondaryAction = BillState.Action.Cancel( - action = { dismissBill(PutInWallet) } - ), - ) - } - } - awaitBillGrab(bill, owner) - } - } - } - - } - } - - private fun awaitBillGrab(bill: Bill, owner: AccountCluster) { - analytics.transferStart(Analytics.Transfer.Initiate.GiveBillStart) - billController.awaitGrab( - amount = bill.amount, - token = bill.token, - verifiedState = (bill as? Bill.Cash)?.verifiedState, - nonce = (bill as? Bill.Cash)?.nonce?.takeIf { it.isNotEmpty() }, - owner = owner, - onGrabbed = { amount -> - tokenCoordinator.subtract(bill.token, amount) - analytics.transfer(Analytics.Transfer.GiveBill, bill.amount) - toastController.enqueue(bill.amount, isDeposit = false) - dismissBill(Grabbed) - vibrator.vibrate() - bringActivityFeedCurrent() - }, - onTimeout = { - dismissBill(action = PutInWallet) - }, - onError = { - analytics.transfer( - event = Analytics.Transfer.GiveBill, - amount = bill.amount, - successful = false, - error = it - ) - dismissBill(action = PutInWallet) - BottomBarManager.showError( - title = resources.getString(R.string.error_title_CashReturnedToWallet), - message = resources.getString(R.string.error_description_CashReturnedToWallet) - ) - }, - present = { (data, nonce) -> - if (!bill.didReceive) { - trace( - tag = "Session", - message = "Pull out cash", - metadata = { - "underlying quarks" to bill.amount.underlyingTokenAmount.quarks - "native amount" to bill.amount.nativeAmount.formatted() - "fx" to bill.amount.rate.fx - "currency" to bill.amount.rate.currency.name - "token mint" to bill.amount.mint - }, - type = TraceType.User, - ) - } - presentBillToUser(data, nonce, bill) - }, - ) - } - - private fun shareGiftCard( - amount: LocalFiat, - token: Token, - owner: AccountCluster, - verifiedState: VerifiedState, - restartBillGrabber: () -> Unit - ) { - val giftCard = GiftCardAccount.create(token) - val shareable = Shareable.CashLink( - giftCardAccount = giftCard, - amount = amount, - autoConfirmationAfter = 60.seconds - ) - - scope.launch { - shareSheetController.onShared = onShared@{ result -> - when (result) { - is ShareResult.ActionTaken -> { - if (!giftCardFundingInProgress.compareAndSet(false, true)) return@onShared - scope.launch action@{ - // immediately fund the gift card - val fundingResult = initiateGiftCardFunding( - giftCard = giftCard, - owner = owner, - amount = amount, - token = token, - verifiedState = verifiedState - ) - if (fundingResult.isFailure) { - return@action - } - - // remain isChecking state until confirmation - shareSheetController.reset(setChecked = true) - - // delay _slightly_ before presenting confirmation - delay(CASH_LINK_CONFIRMATION_DELAY) - - confirmGiftCardSent( - owner = owner, - giftCard = giftCard, - amount = amount, - shareable = shareable, - result = result - ) - }.invokeOnCompletion { giftCardFundingInProgress.set(false) } - } - - ShareResult.NotShared -> { - restartBillGrabber() - } - } - } - shareSheetController.present(shareable) - } - } - - private suspend fun confirmGiftCardSent( - owner: AccountCluster, - giftCard: GiftCardAccount, - amount: LocalFiat, - shareable: Shareable, - result: ShareResult.ActionTaken, - ) { - // confirm the result of the share - val confirmResult = - shareConfirmationController.confirm(shareable, result) - - // reset isChecking after confirmation - shareSheetController.reset(setChecked = false) - - when (confirmResult) { - ShareConfirmationResult.Cancelled -> { - BottomBarManager.showAlert( - title = "Are You Sure?", - message = "Anyone you sent the link to won’t be able to collect the cash", - actions = listOf( - BottomBarAction( - text = "Yes", - onClick = { - // user selected cancel, dismiss everything back to camera - scope.launch { - cancelGiftCard(owner, giftCard) - } - } - ), - BottomBarAction( - text = "Nevermind", - style = BottomBarManager.BottomBarButtonStyle.Text, - onClick = { - scope.launch { - confirmGiftCardSent( - owner, - giftCard, - amount, - shareable, - result - ) - } - } - ) - ), - isDismissible = false, - showCancel = false, - showScrim = false, - ) - } - - is ShareConfirmationResult.Confirmed -> { - when (result) { - ShareResult.CopiedToClipboard -> { - toastController.enqueue(amount, isDeposit = false) - dismissBill(Grabbed) - vibrator.vibrate() - bringActivityFeedCurrent() - analytics.transfer(Analytics.Transfer.SentCashLink.Clipboard, amount) - trace( - tag = "Session", - message = "Cash link copied", - metadata = { - "underlying quarks" to amount.underlyingTokenAmount.quarks - "native amount" to amount.nativeAmount.formatted() - "fx" to amount.rate.fx - "currency" to amount.rate.currency.name - "token mint" to amount.mint - }, - type = TraceType.User, - ) - } - - is ShareResult.SharedToApp -> { - toastController.enqueue(amount, isDeposit = false) - dismissBill(Grabbed) - vibrator.vibrate() - bringActivityFeedCurrent() - - analytics.transfer( - event = Analytics.Transfer.SentCashLink.App(name = result.to), - amount = amount - ) - - trace( - tag = "Session", - message = "Cash link shared", - metadata = { - "target" to result.to - "underlying quarks" to amount.underlyingTokenAmount.quarks - "native amount" to amount.nativeAmount.formatted() - "fx" to amount.rate.fx - "currency" to amount.rate.currency.name - "token mint" to amount.mint - }, - type = TraceType.User, - ) - } - } - } - } - } - - private suspend fun initiateGiftCardFunding( - giftCard: GiftCardAccount, - owner: AccountCluster, - amount: LocalFiat, - token: Token, - verifiedState: VerifiedState, - ): Result = suspendCancellableCoroutine { cont -> - billController.fundGiftCard( - giftCard = giftCard, - amount = amount, - token = token, - owner = owner, - verifiedState = verifiedState, - onFunded = { - tokenCoordinator.subtract(token, amount) - shareSheetController.reset() - cont.resume(Result.success(it)) - }, - onError = { - dismissBill(PutInWallet) - BottomBarManager.showError( - title = resources.getString(R.string.error_title_failedToCreateGiftCard), - message = resources.getString(R.string.error_description_failedToCreateGiftCard) - ) - cont.resume(Result.failure(it)) - } - ) - } - - private suspend fun cancelGiftCard( - owner: AccountCluster, - giftCard: GiftCardAccount - ) { - transactionController.cancelRemoteSend( - vault = giftCard.cluster.vaultPublicKey, - owner = owner, - ).onFailure { - dismissBill(PutInWallet) - }.onSuccess { - tokenCoordinator.update() - checkPendingItemsInFeed() - dismissBill(PutInWallet) - } - } - - override fun onCodeScan(code: ScannableKikCode) { - if (billController.state.value.bill != null) { - return - } - - val payload = (code as? ScannableKikCode.RemoteKikCode)?.payloadId?.toList() ?: return - val codePayload = OpenCodePayload.fromList(payload) - if (scannedRendezvous.contains(codePayload.rendezvous.publicKey)) { - return - } - - if (state.value.vibrateOnScan) { - vibrator.tick() - } - - trace( - tag = "Session", - message = """ - Kind: ${codePayload.kind} - Nonce: ${codePayload.nonce.hexEncodedString()} - Rendezvous: ${codePayload.rendezvous.publicKeyBytes.base58} - """.trimIndent() - ) - - when (codePayload.kind) { - PayloadKind.Cash -> onCashScanned(codePayload) - PayloadKind.MultiMintCash -> onCashScanned(codePayload) - PayloadKind.Unknown -> Unit - } - } - - override fun openCashLink(cashLink: String?) { - BottomBarManager.clear() - - val entropy = cashLink?.trim()?.replace("\n", "") - if (entropy == null) { - trace( - tag = "Session", - message = "Cash link not provided", - type = TraceType.Silent - ) - analytics.deeplinkRouted( - DeeplinkType.CashLink(), - error = IllegalArgumentException("Cash link not provided") - ) - return - } - val owner = userManager.accountCluster - - if (owner == null) { - trace( - tag = "Session", - message = "No owner found", - type = TraceType.Silent - ) - analytics.deeplinkRouted( - DeeplinkType.CashLink(), - error = IllegalStateException("No owner found") - ) - return - } - - if (entropy.isEmpty()) { - trace( - tag = "Session", - message = "Cash link empty", - type = TraceType.Silent - ) - analytics.deeplinkRouted( - DeeplinkType.CashLink(), - error = IllegalArgumentException("Cash link empty") - ) - return - } - - if (giftCardClaimInProgress.value == null) { - giftCardClaimInProgress.value = entropy - analytics.deeplinkRouted(DeeplinkType.CashLink()) // entropy omitted since not needed for analytics - claimGiftCard(owner = owner, entropy = entropy, claimIfOwned = false) - } - } - - override fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)?) { - val depositFirstUx = state.value.depositFirstUx - - val navigate = { route: AppRoute -> - onRoute?.invoke(route) - } - - val message = if (depositFirstUx) { - resources.getString(R.string.description_noBalanceYet) - } else { - resources.getString(R.string.description_noBalanceYetDiscover) - } - val cta = if (depositFirstUx) { - resources.getString(R.string.action_depositFunds) - } else { - resources.getString(R.string.action_discoverCurrencies) - } - - BottomBarManager.showInfo( - title = resources.getString(R.string.title_noBalanceYet), - message = message, - actions = listOf( - BottomBarAction( - text = cta - ) { - scope.launch { - if (depositFirstUx) { - val destination = purchaseMethodController.presentDepositOptions(popToRoot = true) - if (destination != null) { - navigate(destination) - } - } else { - navigate(AppRoute.Token.Discovery) - } - } - }, - ), - showCancel = true, - ) - } - - private fun claimGiftCard( - owner: AccountCluster, - entropy: String, - claimIfOwned: Boolean - ) { - trace( - tag = "Session", - message = "Claiming gift card: $entropy", - type = TraceType.Silent - ) - - billController.receiveGiftCard( - entropy = entropy, - owner = owner, - claimIfOwned = claimIfOwned, - onReceived = { token, amount -> - tokenCoordinator.add(token, amount) - giftCardClaimInProgress.value = null - analytics.transfer(Analytics.Transfer.ClaimedCashLink, amount = amount) - val bill = Bill.Cash( - amount = amount, - token = token, - didReceive = true, - ) - showBill(bill) - checkPendingItemsInFeed() - bringActivityFeedCurrent() - }, - onError = { cause -> - giftCardClaimInProgress.value = null - if (cause !is ReceiveGiftTransactorError.UsersGiftCard) { - analytics.transfer( - Analytics.Transfer.ClaimedCashLink, - amount = null, - successful = false, - error = cause - ) - } - - when (cause) { - is ReceiveGiftTransactorError.UsersGiftCard -> { - // present confirmation to claim (cancel) own gift card - BottomBarManager.showAlert( - title = resources.getString(R.string.prompt_title_collectOwnCash), - message = resources.getString(R.string.prompt_description_collectOwnCash), - isDismissible = false, - showScrim = false, - actions = buildList { - add( - BottomBarAction( - text = resources.getString(R.string.action_dontCollect), - ) - ) - - add( - BottomBarAction( - text = resources.getString(R.string.action_collect), - style = BottomBarManager.BottomBarButtonStyle.Text, - onClick = { - claimGiftCard( - owner = owner, - entropy = entropy, - // confirmed claim of own cash link - claimIfOwned = true - ) - } - ) - ) - } - ) - } - - is ReceiveGiftTransactorError.AlreadyClaimed -> { - BottomBarManager.showAlert( - resources.getString(R.string.error_title_alreadyCollected), - resources.getString(R.string.error_description_alreadyCollected) - ) - } - - is ReceiveGiftTransactorError.Expired -> { - BottomBarManager.showAlert( - resources.getString(R.string.error_title_linkExpired), - resources.getString(R.string.error_description_linkExpired) - ) - } - - else -> { - BottomBarManager.showError( - resources.getString(R.string.error_title_failedToCollect), - resources.getString(R.string.error_description_failedToCollect) - ) - } - } - } - ) - } - - private fun onCashScanned(payload: OpenCodePayload) { - scannedRendezvous[payload.rendezvous.publicKey] = Clock.System.now().toEpochMilliseconds() - - trace( - tag = "Session", - message = "Scanned: ${payload.fiat!!.quarks} ${payload.fiat!!.currencyCode}" - ) - val owner = userManager.accountCluster ?: return - - analytics.transferStart(Analytics.Transfer.Initiate.GrabBillStart) - billController.attemptGrab( - owner = owner, - payload = payload, - onGrabbed = { token, amount, verifiedState -> - tokenCoordinator.add(token, amount) - val grabStart = scannedRendezvous[payload.rendezvous.publicKey] - val grabTime = grabStart?.let { - Clock.System.now().toEpochMilliseconds() - it - } - - val bill = Bill.Cash( - amount = amount, - token = token, - didReceive = true, - verifiedState = verifiedState - ) - showBill(bill) - - analytics.transfer(Analytics.Transfer.GrabBill(grabTime), amount) - BottomBarManager.clear() - checkPendingItemsInFeed() - bringActivityFeedCurrent() - }, - onError = { - analytics.transfer( - event = Analytics.Transfer.GrabBill(), - fiat = payload.fiat, - successful = false, - error = it - ) - scannedRendezvous.remove(payload.rendezvous.publicKey) - } - ) - } - - private fun presentBillToUser(data: List, nonce: List, bill: Bill) { - if (billController.state.value.bill != null) return - - val presentedBill = when (bill) { - is Bill.Cash -> bill.copy( - data = data, - nonce = nonce, - ) - } - - billController.update { - it.copy( - bill = presentedBill, - valuation = PaymentValuation(bill.amount.nativeAmount), - ) - } - - val style: BillDeterminationResult = - if (bill.didReceive) Grabbed else PutInWallet - - _state.update { it.copy(billResult = style) } - - if (bill.didReceive) { - // enqueue toast - toastController.enqueue(bill.amount, isDeposit = true) - // shorter punch than standard - vibrator.vibrate(duration = 50) - } - } - - - override fun dismissBill(action: BillDeterminationResult) { - scope.launch { - _state.update { it.copy(billResult = action) } - billController.reset() - toastController.consumeQueue() - } - } } - -private val CASH_LINK_CONFIRMATION_DELAY = 500.milliseconds \ No newline at end of file diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt new file mode 100644 index 000000000..b64518593 --- /dev/null +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.session.internal + +import com.flipcash.app.session.SessionState +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Thread-safe holder for the current [SessionState]. + * + * Injected into every session delegate and the [RealSessionController] shell so they + * share a single source of truth for session-wide state (auth flags, camera state, + * feature flags, balances, etc.) without any delegate holding the raw + * `MutableStateFlow`. + */ +@Singleton +class SessionStateHolder @Inject constructor() { + private val _state = MutableStateFlow(SessionState()) + + /** Observable session state, collected by `RealSessionController.state`. */ + val state: StateFlow = _state.asStateFlow() + + /** Snapshot of the current state — useful in non-suspending contexts. */ + val current: SessionState get() = _state.value + + /** Atomically update the state via a transform function. */ + fun update(transform: (SessionState) -> SessionState) { _state.update(transform) } + + /** Reset to the default [SessionState] (e.g. on logout). */ + fun reset() { _state.value = SessionState() } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt new file mode 100644 index 000000000..3e9dae7c6 --- /dev/null +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt @@ -0,0 +1,217 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.bill.BillState +import com.flipcash.app.core.bill.PaymentValuation +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.core.internal.errors.showNetworkError +import com.flipcash.app.session.BillDeterminationResult +import com.flipcash.app.session.BillOperations +import com.flipcash.app.session.Grabbed +import com.flipcash.app.session.PutInWallet +import com.flipcash.app.session.internal.SessionStateHolder +import com.flipcash.app.session.internal.toast.SessionToastController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.core.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.vibration.Vibrator +import com.getcode.utils.ErrorUtils +import com.getcode.utils.TraceType +import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implements [BillOperations] — creating, presenting, and dismissing cash bills. + * + * This delegate handles the full lifecycle of a bill that the user "gives": + * 1. Validates the amount and network connectivity. + * 2. Configures bill actions (Send-as-Link, Cancel). + * 3. Starts the "await grab" flow that renders the Kik Code and waits for a scan. + * 4. On grab: subtracts the token balance, enqueues a toast, and emits [Event.RefreshFeed]. + * 5. On Send-as-Link tap: cancels the grab and emits [Event.SendAsLinkRequested] so + * the shell can route to [GiftCardSharingDelegate]. + * + * Cross-delegate communication is handled via [events]; the [com.flipcash.app.session.internal.RealSessionController] + * shell collects these and dispatches to the appropriate delegate. + * + * @see com.flipcash.app.session.internal.RealSessionController + */ +@Singleton +class BillPresentationDelegate @Inject constructor( + private val billController: BillController, + private val stateHolder: SessionStateHolder, + private val toastController: SessionToastController, + private val tokenCoordinator: TokenCoordinator, + private val analytics: FlipcashAnalyticsService, + private val vibrator: Vibrator, + private val resources: ResourceHelper, + private val networkObserver: NetworkConnectivityListener, + private val userManager: UserManager, + dispatchers: DispatcherProvider, +) : BillOperations { + + sealed interface Event { + data class SendAsLinkRequested(val bill: Bill.Cash, val owner: AccountCluster) : Event + data object RefreshFeed : Event + } + + private val scope = CoroutineScope(dispatchers.IO + SupervisorJob()) + + private val _events = Channel(Channel.UNLIMITED) + val events: Flow = _events.consumeAsFlow() + + override val billState: StateFlow get() = billController.state + + override fun showBill(bill: Bill) { + if (bill.amount.nativeAmount.decimalValue == 0.0) return + val owner = userManager.accountCluster ?: return + + if (!networkObserver.isConnected) { + return ErrorUtils.showNetworkError(resources) + } + + when (bill) { + is Bill.Cash -> { + when (bill.kind) { + Bill.Kind.airdrop -> { + billController.update { + it.copy( + primaryAction = null, + secondaryAction = null, + ) + } + } + + Bill.Kind.cash -> { + if (bill.didReceive) { + billController.update { + it.copy( + primaryAction = null, + secondaryAction = null, + ) + } + } else { + billController.update { + it.copy( + primaryAction = BillState.Action.SendAsLink( + action = { + billController.cancelAwaitForGrab() + _events.trySend(Event.SendAsLinkRequested(bill, owner)) + } + ), + secondaryAction = BillState.Action.Cancel( + action = { dismissBill(PutInWallet) } + ), + ) + } + } + awaitBillGrab(bill, owner) + } + } + } + } + } + + override fun dismissBill(action: BillDeterminationResult) { + scope.launch { + stateHolder.update { it.copy(billResult = action) } + billController.reset() + toastController.consumeQueue() + } + } + + internal fun awaitBillGrab(bill: Bill, owner: AccountCluster) { + analytics.transferStart(Analytics.Transfer.Initiate.GiveBillStart) + billController.awaitGrab( + amount = bill.amount, + token = bill.token, + verifiedState = (bill as? Bill.Cash)?.verifiedState, + nonce = (bill as? Bill.Cash)?.nonce?.takeIf { it.isNotEmpty() }, + owner = owner, + onGrabbed = { amount -> + tokenCoordinator.subtract(bill.token, amount) + analytics.transfer(Analytics.Transfer.GiveBill, bill.amount) + toastController.enqueue(bill.amount, isDeposit = false) + dismissBill(Grabbed) + vibrator.vibrate() + _events.trySend(Event.RefreshFeed) + }, + onTimeout = { + dismissBill(action = PutInWallet) + }, + onError = { + analytics.transfer( + event = Analytics.Transfer.GiveBill, + amount = bill.amount, + successful = false, + error = it + ) + dismissBill(action = PutInWallet) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_CashReturnedToWallet), + message = resources.getString(R.string.error_description_CashReturnedToWallet) + ) + }, + present = { (data, nonce) -> + if (!bill.didReceive) { + trace( + tag = "Session", + message = "Pull out cash", + metadata = { + "underlying quarks" to bill.amount.underlyingTokenAmount.quarks + "native amount" to bill.amount.nativeAmount.formatted() + "fx" to bill.amount.rate.fx + "currency" to bill.amount.rate.currency.name + "token mint" to bill.amount.mint + }, + type = TraceType.User, + ) + } + presentBillToUser(data, nonce, bill) + }, + ) + } + + private fun presentBillToUser(data: List, nonce: List, bill: Bill) { + if (billController.state.value.bill != null) return + + val presentedBill = when (bill) { + is Bill.Cash -> bill.copy( + data = data, + nonce = nonce, + ) + } + + billController.update { + it.copy( + bill = presentedBill, + valuation = PaymentValuation(bill.amount.nativeAmount), + ) + } + + val style: BillDeterminationResult = + if (bill.didReceive) Grabbed else PutInWallet + + stateHolder.update { it.copy(billResult = style) } + + if (bill.didReceive) { + toastController.enqueue(bill.amount, isDeposit = true) + vibrator.vibrate(duration = 50) + } + } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt new file mode 100644 index 000000000..f3e1e8cf6 --- /dev/null +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt @@ -0,0 +1,206 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.app.session.CashLinkOperations +import com.flipcash.app.session.internal.SessionStateHolder +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.core.R +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.internal.transactors.ReceiveGiftTransactorError +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.consumeAsFlow +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implements [CashLinkOperations] — opening (claiming) cash links. + * + * 1. Validates the entropy string (null, empty, whitespace). + * 2. Guards against duplicate concurrent claims via [giftCardClaimInProgress]. + * 3. Calls [BillController.receiveGiftCard] to claim the gift card. + * 4. On success: adds the token to the balance and emits [Event.BillReady], + * [Event.CheckPendingFeed], and [Event.RefreshFeed] for the shell to route. + * 5. On error: shows the appropriate error dialog (already claimed, expired, + * user's own gift card with a "collect anyway?" prompt, or generic error). + * + * @see com.flipcash.app.session.internal.RealSessionController + */ +@Singleton +class CashLinkDelegate @Inject constructor( + private val stateHolder: SessionStateHolder, + private val billController: BillController, + private val tokenCoordinator: TokenCoordinator, + private val analytics: FlipcashAnalyticsService, + private val resources: ResourceHelper, + private val userManager: UserManager, +) : CashLinkOperations { + + sealed interface Event { + data class BillReady(val bill: Bill) : Event + data object RefreshFeed : Event + data object CheckPendingFeed : Event + } + + private val _events = Channel(Channel.UNLIMITED) + val events: Flow = _events.consumeAsFlow() + + private val giftCardClaimInProgress = MutableStateFlow(null) + + override fun openCashLink(cashLink: String?) { + BottomBarManager.clear() + + val entropy = cashLink?.trim()?.replace("\n", "") + if (entropy == null) { + trace( + tag = "Session", + message = "Cash link not provided", + type = TraceType.Silent + ) + analytics.deeplinkRouted( + DeeplinkType.CashLink(), + error = IllegalArgumentException("Cash link not provided") + ) + return + } + val owner = userManager.accountCluster + + if (owner == null) { + trace( + tag = "Session", + message = "No owner found", + type = TraceType.Silent + ) + analytics.deeplinkRouted( + DeeplinkType.CashLink(), + error = IllegalStateException("No owner found") + ) + return + } + + if (entropy.isEmpty()) { + trace( + tag = "Session", + message = "Cash link empty", + type = TraceType.Silent + ) + analytics.deeplinkRouted( + DeeplinkType.CashLink(), + error = IllegalArgumentException("Cash link empty") + ) + return + } + + if (giftCardClaimInProgress.value == null) { + giftCardClaimInProgress.value = entropy + analytics.deeplinkRouted(DeeplinkType.CashLink()) + claimGiftCard(owner = owner, entropy = entropy, claimIfOwned = false) + } + } + + private fun claimGiftCard( + owner: AccountCluster, + entropy: String, + claimIfOwned: Boolean + ) { + trace( + tag = "Session", + message = "Claiming gift card: $entropy", + type = TraceType.Silent + ) + + billController.receiveGiftCard( + entropy = entropy, + owner = owner, + claimIfOwned = claimIfOwned, + onReceived = { token, amount -> + tokenCoordinator.add(token, amount) + giftCardClaimInProgress.value = null + analytics.transfer(Analytics.Transfer.ClaimedCashLink, amount = amount) + val bill = Bill.Cash( + amount = amount, + token = token, + didReceive = true, + ) + _events.trySend(Event.BillReady(bill)) + _events.trySend(Event.CheckPendingFeed) + _events.trySend(Event.RefreshFeed) + }, + onError = { cause -> + giftCardClaimInProgress.value = null + if (cause !is ReceiveGiftTransactorError.UsersGiftCard) { + analytics.transfer( + Analytics.Transfer.ClaimedCashLink, + amount = null, + successful = false, + error = cause + ) + } + + when (cause) { + is ReceiveGiftTransactorError.UsersGiftCard -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_collectOwnCash), + message = resources.getString(R.string.prompt_description_collectOwnCash), + isDismissible = false, + showScrim = false, + actions = buildList { + add( + BottomBarAction( + text = resources.getString(R.string.action_dontCollect), + ) + ) + + add( + BottomBarAction( + text = resources.getString(R.string.action_collect), + style = BottomBarManager.BottomBarButtonStyle.Text, + onClick = { + claimGiftCard( + owner = owner, + entropy = entropy, + claimIfOwned = true + ) + } + ) + ) + } + ) + } + + is ReceiveGiftTransactorError.AlreadyClaimed -> { + BottomBarManager.showAlert( + resources.getString(R.string.error_title_alreadyCollected), + resources.getString(R.string.error_description_alreadyCollected) + ) + } + + is ReceiveGiftTransactorError.Expired -> { + BottomBarManager.showAlert( + resources.getString(R.string.error_title_linkExpired), + resources.getString(R.string.error_description_linkExpired) + ) + } + + else -> { + BottomBarManager.showError( + resources.getString(R.string.error_title_failedToCollect), + resources.getString(R.string.error_description_failedToCollect) + ) + } + } + } + ) + } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt new file mode 100644 index 000000000..3aa10e605 --- /dev/null +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt @@ -0,0 +1,143 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.session.CodeScanOperations +import com.flipcash.app.session.internal.SessionStateHolder +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.core.OpenCodePayload +import com.getcode.opencode.model.core.PayloadKind +import com.getcode.util.vibration.Vibrator +import com.getcode.utils.base58 +import com.getcode.utils.hexEncodedString +import com.getcode.utils.trace +import com.kik.kikx.models.ScannableKikCode +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.time.Clock + +/** + * Implements [CodeScanOperations] — handling QR (Kik Code) scans and grab attempts. + * + * When a user scans a cash code: + * 1. Deduplicates by rendezvous key (prevents double-grabs of the same code). + * 2. Parses the [OpenCodePayload] to determine the kind (Cash, MultiMintCash). + * 3. Optionally vibrates on scan (controlled by the `vibrateOnScan` feature flag). + * 4. Calls [BillController.attemptGrab] to claim the funds. + * 5. On success: adds the token to the balance and emits [Event.BillReady], + * [Event.CheckPendingFeed], and [Event.RefreshFeed] for the shell to route. + * + * The rendezvous dedup map is cleared per-key on grab error so the user can retry. + * + * @see com.flipcash.app.session.internal.RealSessionController + */ +@Singleton +class CodeScanDelegate @Inject constructor( + private val stateHolder: SessionStateHolder, + private val billController: BillController, + private val tokenCoordinator: TokenCoordinator, + private val analytics: FlipcashAnalyticsService, + private val vibrator: Vibrator, + private val userManager: UserManager, + private val dispatchers: DispatcherProvider, +) : CodeScanOperations { + + sealed interface Event { + data class BillReady(val bill: Bill) : Event + data object RefreshFeed : Event + data object CheckPendingFeed : Event + } + + private val _events = Channel(Channel.UNLIMITED) + val events: Flow = _events.consumeAsFlow() + + private val scannedRendezvous = mutableMapOf() + + override fun onCameraScanning(scanning: Boolean) { + stateHolder.update { it.copy(isCameraUp = scanning) } + } + + override fun onCodeScan(code: ScannableKikCode) { + if (billController.state.value.bill != null) { + return + } + + val payload = (code as? ScannableKikCode.RemoteKikCode)?.payloadId?.toList() ?: return + val codePayload = OpenCodePayload.fromList(payload) + if (scannedRendezvous.contains(codePayload.rendezvous.publicKey)) { + return + } + + if (stateHolder.current.vibrateOnScan) { + vibrator.tick() + } + + trace( + tag = "Session", + message = """ + Kind: ${codePayload.kind} + Nonce: ${codePayload.nonce.hexEncodedString()} + Rendezvous: ${codePayload.rendezvous.publicKeyBytes.base58} + """.trimIndent() + ) + + when (codePayload.kind) { + PayloadKind.Cash -> onCashScanned(codePayload) + PayloadKind.MultiMintCash -> onCashScanned(codePayload) + PayloadKind.Unknown -> Unit + } + } + + private fun onCashScanned(payload: OpenCodePayload) { + scannedRendezvous[payload.rendezvous.publicKey] = Clock.System.now().toEpochMilliseconds() + + trace( + tag = "Session", + message = "Scanned: ${payload.fiat!!.quarks} ${payload.fiat!!.currencyCode}" + ) + val owner = userManager.accountCluster ?: return + + analytics.transferStart(Analytics.Transfer.Initiate.GrabBillStart) + billController.attemptGrab( + owner = owner, + payload = payload, + onGrabbed = { token, amount, verifiedState -> + tokenCoordinator.add(token, amount) + val grabStart = scannedRendezvous[payload.rendezvous.publicKey] + val grabTime = grabStart?.let { + Clock.System.now().toEpochMilliseconds() - it + } + + val bill = Bill.Cash( + amount = amount, + token = token, + didReceive = true, + verifiedState = verifiedState + ) + _events.trySend(Event.BillReady(bill)) + + analytics.transfer(Analytics.Transfer.GrabBill(grabTime), amount) + BottomBarManager.clear() + _events.trySend(Event.CheckPendingFeed) + _events.trySend(Event.RefreshFeed) + }, + onError = { + analytics.transfer( + event = Analytics.Transfer.GrabBill(), + fiat = payload.fiat, + successful = false, + error = it + ) + scannedRendezvous.remove(payload.rendezvous.publicKey) + } + ) + } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt new file mode 100644 index 000000000..8e0903ab9 --- /dev/null +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt @@ -0,0 +1,100 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.core.AppRoute +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.payments.PurchaseMethodController +import com.flipcash.app.session.DepositOperations +import com.flipcash.app.session.internal.SessionStateHolder +import com.flipcash.app.tokens.UsdcDepositSweep +import com.flipcash.core.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.util.resources.ResourceHelper +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Implements [DepositOperations] and manages USDC deposit sweeps. + * + * This delegate owns the "getting money into the wallet" domain: + * - Presenting deposit/discovery options when the wallet is empty. + * - Observing the `depositFirstUx` feature flag. + * - Executing and cancelling USDC deposit sweeps on lifecycle transitions. + * + * @see com.flipcash.app.session.internal.RealSessionController + */ +@Singleton +class DepositDelegate @Inject constructor( + private val stateHolder: SessionStateHolder, + private val purchaseMethodController: PurchaseMethodController, + private val usdcSweep: UsdcDepositSweep, + private val userManager: UserManager, + private val resources: ResourceHelper, + dispatchers: DispatcherProvider, + featureFlagController: FeatureFlagController, +) : DepositOperations { + + private val scope = CoroutineScope(dispatchers.IO + SupervisorJob()) + + init { + featureFlagController.observe(FeatureFlag.DepositFirstUX) + .onEach { enabled -> stateHolder.update { it.copy(depositFirstUx = enabled) } } + .launchIn(scope) + } + + override fun presentDepositOptions(onRoute: ((AppRoute) -> Unit)?) { + val depositFirstUx = stateHolder.current.depositFirstUx + + val message = if (depositFirstUx) { + resources.getString(R.string.description_noBalanceYet) + } else { + resources.getString(R.string.description_noBalanceYetDiscover) + } + val cta = if (depositFirstUx) { + resources.getString(R.string.action_depositFunds) + } else { + resources.getString(R.string.action_discoverCurrencies) + } + + BottomBarManager.showInfo( + title = resources.getString(R.string.title_noBalanceYet), + message = message, + actions = listOf( + BottomBarAction( + text = cta + ) { + scope.launch { + if (depositFirstUx) { + val destination = purchaseMethodController.presentDepositOptions(popToRoot = true) + if (destination != null) { + onRoute?.invoke(destination) + } + } else { + onRoute?.invoke(AppRoute.Token.Discovery) + } + } + }, + ), + showCancel = true, + ) + } + + fun sweepIfNeeded() { + val owner = userManager.accountCluster ?: return + if (userManager.authState.canAccessAuthenticatedApis) { + usdcSweep.execute(owner) + } + } + + fun cancelSweep() { + usdcSweep.cancel() + } +} diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt new file mode 100644 index 000000000..4a56d226b --- /dev/null +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt @@ -0,0 +1,297 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.session.BillDeterminationResult +import com.flipcash.app.session.Grabbed +import com.flipcash.app.session.PutInWallet +import com.flipcash.app.session.internal.toast.SessionToastController +import com.flipcash.app.shareable.ShareConfirmationResult +import com.flipcash.app.shareable.ShareResult +import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.app.shareable.Shareable +import com.flipcash.app.shareable.ShareableConfirmationController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.core.R +import com.flipcash.libs.coroutines.DispatcherProvider +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.accounts.GiftCardAccount +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.vibration.Vibrator +import com.getcode.utils.TraceType +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.util.concurrent.atomic.AtomicBoolean +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Duration.Companion.seconds + +/** + * Internal delegate that manages the "Send as Link" gift-card flow. + * + * Unlike the three `by`-delegated interfaces, this class is **not** exposed on the + * public [SessionController] API — it is an internal worker invoked by the shell when + * [BillPresentationDelegate] emits [BillPresentationDelegate.Event.SendAsLinkRequested]. + * + * The flow: + * 1. Creates a [GiftCardAccount] and presents the share sheet. + * 2. If the user shares: funds the gift card on-chain, waits for confirmation, then + * emits [Event.DismissBill] and [Event.RefreshFeed]. + * 3. If the user cancels the share sheet without sharing: emits + * [Event.RestartBillGrab] so the shell can re-enter the "await grab" flow. + * 4. If the user cancels the confirmation: prompts "Are you sure?", and on + * confirmation cancels the remote send and returns funds. + * + * Funding is guarded by [giftCardFundingInProgress] (`AtomicBoolean`) to prevent + * double-funding from rapid taps. + * + * @see com.flipcash.app.session.internal.RealSessionController + */ +@Singleton +class GiftCardSharingDelegate @Inject constructor( + private val billController: BillController, + private val shareSheetController: ShareSheetController, + private val shareConfirmationController: ShareableConfirmationController, + private val toastController: SessionToastController, + private val tokenCoordinator: TokenCoordinator, + private val transactionController: TransactionController, + private val analytics: FlipcashAnalyticsService, + private val vibrator: Vibrator, + private val resources: ResourceHelper, + dispatchers: DispatcherProvider, +) { + + sealed interface Event { + data class DismissBill(val action: BillDeterminationResult) : Event + data class RestartBillGrab(val bill: Bill, val owner: AccountCluster) : Event + data object RefreshFeed : Event + } + + private val scope = CoroutineScope(dispatchers.IO + SupervisorJob()) + + private val _events = Channel(Channel.UNLIMITED) + val events: Flow = _events.consumeAsFlow() + + private val giftCardFundingInProgress = AtomicBoolean(false) + + fun shareGiftCard(bill: Bill.Cash, owner: AccountCluster) { + val amount = bill.amount + val token = bill.token + val verifiedState = bill.verifiedState!! + + val giftCard = GiftCardAccount.create(token) + val shareable = Shareable.CashLink( + giftCardAccount = giftCard, + amount = amount, + autoConfirmationAfter = 60.seconds + ) + + scope.launch { + shareSheetController.onShared = onShared@{ result -> + when (result) { + is ShareResult.ActionTaken -> { + if (!giftCardFundingInProgress.compareAndSet(false, true)) return@onShared + scope.launch action@{ + val fundingResult = initiateGiftCardFunding( + giftCard = giftCard, + owner = owner, + amount = amount, + token = token, + verifiedState = verifiedState + ) + if (fundingResult.isFailure) { + return@action + } + + shareSheetController.reset(setChecked = true) + + delay(CASH_LINK_CONFIRMATION_DELAY) + + confirmGiftCardSent( + owner = owner, + giftCard = giftCard, + amount = amount, + shareable = shareable, + result = result + ) + }.invokeOnCompletion { giftCardFundingInProgress.set(false) } + } + + ShareResult.NotShared -> { + trace( + tag = "Session", + message = "Cash link not sent. Restarting awaiting grab", + type = TraceType.User, + ) + val currentBill = billController.state.value.bill ?: bill + _events.trySend(Event.RestartBillGrab(currentBill, owner)) + } + } + } + shareSheetController.present(shareable) + } + } + + private suspend fun confirmGiftCardSent( + owner: AccountCluster, + giftCard: GiftCardAccount, + amount: LocalFiat, + shareable: Shareable, + result: ShareResult.ActionTaken, + ) { + val confirmResult = + shareConfirmationController.confirm(shareable, result) + + shareSheetController.reset(setChecked = false) + + when (confirmResult) { + ShareConfirmationResult.Cancelled -> { + BottomBarManager.showAlert( + title = resources.getString(R.string.prompt_title_cancelCashLinkPostShare), + message = resources.getString(R.string.prompt_description_cancelCashLinkPostShare), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_yes), + onClick = { + scope.launch { + cancelGiftCard(owner, giftCard) + } + } + ), + BottomBarAction( + text = resources.getString(R.string.action_nevermind), + style = BottomBarManager.BottomBarButtonStyle.Text, + onClick = { + scope.launch { + confirmGiftCardSent( + owner, + giftCard, + amount, + shareable, + result + ) + } + } + ) + ), + isDismissible = false, + showCancel = false, + showScrim = false, + ) + } + + is ShareConfirmationResult.Confirmed -> { + when (result) { + ShareResult.CopiedToClipboard -> { + toastController.enqueue(amount, isDeposit = false) + _events.trySend(Event.DismissBill(Grabbed)) + vibrator.vibrate() + _events.trySend(Event.RefreshFeed) + analytics.transfer(Analytics.Transfer.SentCashLink.Clipboard, amount) + trace( + tag = "Session", + message = "Cash link copied", + metadata = { + "underlying quarks" to amount.underlyingTokenAmount.quarks + "native amount" to amount.nativeAmount.formatted() + "fx" to amount.rate.fx + "currency" to amount.rate.currency.name + "token mint" to amount.mint + }, + type = TraceType.User, + ) + } + + is ShareResult.SharedToApp -> { + toastController.enqueue(amount, isDeposit = false) + _events.trySend(Event.DismissBill(Grabbed)) + vibrator.vibrate() + _events.trySend(Event.RefreshFeed) + + analytics.transfer( + event = Analytics.Transfer.SentCashLink.App(name = result.to), + amount = amount + ) + + trace( + tag = "Session", + message = "Cash link shared", + metadata = { + "target" to result.to + "underlying quarks" to amount.underlyingTokenAmount.quarks + "native amount" to amount.nativeAmount.formatted() + "fx" to amount.rate.fx + "currency" to amount.rate.currency.name + "token mint" to amount.mint + }, + type = TraceType.User, + ) + } + } + } + } + } + + private suspend fun initiateGiftCardFunding( + giftCard: GiftCardAccount, + owner: AccountCluster, + amount: LocalFiat, + token: Token, + verifiedState: VerifiedState, + ): Result = suspendCancellableCoroutine { cont -> + billController.fundGiftCard( + giftCard = giftCard, + amount = amount, + token = token, + owner = owner, + verifiedState = verifiedState, + onFunded = { + tokenCoordinator.subtract(token, amount) + shareSheetController.reset() + cont.resume(Result.success(it)) + }, + onError = { + _events.trySend(Event.DismissBill(PutInWallet)) + BottomBarManager.showError( + title = resources.getString(R.string.error_title_failedToCreateGiftCard), + message = resources.getString(R.string.error_description_failedToCreateGiftCard) + ) + cont.resume(Result.failure(it)) + } + ) + } + + private suspend fun cancelGiftCard( + owner: AccountCluster, + giftCard: GiftCardAccount + ) { + transactionController.cancelRemoteSend( + vault = giftCard.cluster.vaultPublicKey, + owner = owner, + ).onFailure { + _events.trySend(Event.DismissBill(PutInWallet)) + }.onSuccess { + tokenCoordinator.update() + _events.trySend(Event.DismissBill(PutInWallet)) + } + } +} + +private val CASH_LINK_CONFIRMATION_DELAY = 500.milliseconds diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/toast/ToastController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/toast/SessionToastController.kt similarity index 100% rename from apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/toast/ToastController.kt rename to apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/toast/SessionToastController.kt diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt new file mode 100644 index 000000000..3645a7491 --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerEventRoutingTest.kt @@ -0,0 +1,268 @@ +package com.flipcash.app.session.internal + +import com.flipcash.app.appsettings.AppSettingsCoordinator +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.session.BillDeterminationResult +import com.flipcash.app.session.PutInWallet +import com.flipcash.app.session.internal.delegates.BillPresentationDelegate +import com.flipcash.app.session.internal.delegates.CashLinkDelegate +import com.flipcash.app.session.internal.delegates.CodeScanDelegate +import com.flipcash.app.session.internal.delegates.GiftCardSharingDelegate +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.user.AuthState +import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.utils.network.NetworkState +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class SessionControllerEventRoutingTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + // Controllable event flows for each delegate + private val billDelegateEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val scanDelegateEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val cashLinkDelegateEvents = MutableSharedFlow(extraBufferCapacity = 8) + private val giftCardDelegateEvents = MutableSharedFlow(extraBufferCapacity = 8) + + private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()) + + private val userManager = mockk(relaxed = true) { + every { state } returns MutableStateFlow(UserManager.State(authState = AuthState.Unknown)) + every { authState } returns AuthState.Unknown + } + + private val featureFlagController = mockk(relaxed = true) { + every { observe(any()) } returns MutableStateFlow(false) + } + + private val appSettingsCoordinator = mockk(relaxed = true) { + every { observeValue(any()) } returns flowOf(false) + } + + private val tokenCoordinator = mockk(relaxed = true) { + every { tokenBalances } returns flowOf(emptyList()) + every { tokens } returns flowOf(emptyList()) + } + + private val networkObserver = mockk(relaxed = true) { + every { state } returns MutableStateFlow(NetworkState(connected = false, signalStrength = com.getcode.utils.network.SignalStrength.Unknown, type = com.getcode.utils.network.ConnectionType.Unknown)) + } + + // Mocked delegates — relaxed so all method calls are no-ops by default + private val billDelegate = mockk(relaxed = true) { + every { events } returns billDelegateEvents + every { billState } returns mockk(relaxed = true) { + every { value } returns mockk(relaxed = true) + } + } + + private val scanDelegate = mockk(relaxed = true) { + every { events } returns scanDelegateEvents + } + + private val cashLinkDelegate = mockk(relaxed = true) { + every { events } returns cashLinkDelegateEvents + } + + private val giftCardDelegate = mockk(relaxed = true) { + every { events } returns giftCardDelegateEvents + } + + private val billController = mockk(relaxed = true) + + @Before + fun setUp() { + // Ensure BillController.state is always non-null (accessed in onAppInBackground path) + every { billController.state } returns MutableStateFlow(mockk(relaxed = true)) + } + + private fun createController(): RealSessionController { + return RealSessionController( + billDelegate = billDelegate, + scanDelegate = scanDelegate, + cashLinkDelegate = cashLinkDelegate, + depositDelegate = mockk(relaxed = true), + giftCardDelegate = giftCardDelegate, + stateHolder = SessionStateHolder(), + billController = billController, + userManager = userManager, + accountController = mockk(relaxed = true), + settingsController = mockk(relaxed = true), + feedCoordinator = mockk(relaxed = true), + networkObserver = networkObserver, + tokenUpdater = mockk(relaxed = true), + activityFeedUpdater = mockk(relaxed = true), + profileUpdater = mockk(relaxed = true), + shareSheetController = mockk(relaxed = true), + toastController = mockk(relaxed = true), + billingClient = mockk(relaxed = true), + tokenCoordinator = tokenCoordinator, + contactCoordinator = mockk(relaxed = true), + chatCoordinator = mockk(relaxed = true), + featureFlagController = featureFlagController, + appSettingsCoordinator = appSettingsCoordinator, + dispatchers = dispatchers, + ) + } + + // ------------------------------------------------------------------------- + // CashLinkDelegate.Event.BillReady → billDelegate.showBill + // ------------------------------------------------------------------------- + + @Test + fun `CashLinkDelegate BillReady event routes to showBill on billDelegate`() = runTest { + createController() // init block wires up collectors + + val bill = mockk(relaxed = true) + cashLinkDelegateEvents.emit(CashLinkDelegate.Event.BillReady(bill)) + + advanceUntilIdle() + + verify(exactly = 1) { billDelegate.showBill(bill) } + } + + // ------------------------------------------------------------------------- + // CodeScanDelegate.Event.BillReady → billDelegate.showBill + // ------------------------------------------------------------------------- + + @Test + fun `CodeScanDelegate BillReady event routes to showBill on billDelegate`() = runTest { + createController() + + val bill = mockk(relaxed = true) + scanDelegateEvents.emit(CodeScanDelegate.Event.BillReady(bill)) + + advanceUntilIdle() + + verify(exactly = 1) { billDelegate.showBill(bill) } + } + + // ------------------------------------------------------------------------- + // BillPresentationDelegate.Event.SendAsLinkRequested → giftCardDelegate.shareGiftCard + // ------------------------------------------------------------------------- + + @Test + fun `BillPresentationDelegate SendAsLinkRequested event routes to shareGiftCard on giftCardDelegate`() = runTest { + createController() + + val bill = mockk(relaxed = true) + val owner = mockk(relaxed = true) + billDelegateEvents.emit(BillPresentationDelegate.Event.SendAsLinkRequested(bill, owner)) + + advanceUntilIdle() + + verify(exactly = 1) { giftCardDelegate.shareGiftCard(bill, owner) } + } + + // ------------------------------------------------------------------------- + // GiftCardSharingDelegate.Event.DismissBill → billDelegate.dismissBill + // ------------------------------------------------------------------------- + + @Test + fun `GiftCardSharingDelegate DismissBill event routes to dismissBill on billDelegate`() = runTest { + createController() + + val action: BillDeterminationResult = PutInWallet + giftCardDelegateEvents.emit(GiftCardSharingDelegate.Event.DismissBill(action)) + + advanceUntilIdle() + + // dismissBill on the shell delegates to billDelegate via `by`; verify the call landed there + verify(exactly = 1) { billDelegate.dismissBill(action) } + } + + // ------------------------------------------------------------------------- + // GiftCardSharingDelegate.Event.RestartBillGrab → billDelegate.awaitBillGrab + // ------------------------------------------------------------------------- + + @Test + fun `GiftCardSharingDelegate RestartBillGrab event routes to awaitBillGrab on billDelegate`() = runTest { + createController() + + val bill = mockk(relaxed = true) + val owner = mockk(relaxed = true) + giftCardDelegateEvents.emit(GiftCardSharingDelegate.Event.RestartBillGrab(bill, owner)) + + advanceUntilIdle() + + verify(exactly = 1) { billDelegate.awaitBillGrab(bill, owner) } + } + + // ------------------------------------------------------------------------- + // Guard: unrelated events on one delegate do NOT trigger cross-delegate calls + // ------------------------------------------------------------------------- + + @Test + fun `CodeScanDelegate RefreshFeed event does not call showBill`() = runTest { + createController() + + scanDelegateEvents.emit(CodeScanDelegate.Event.RefreshFeed) + + advanceUntilIdle() + + verify(exactly = 0) { billDelegate.showBill(any()) } + } + + @Test + fun `CashLinkDelegate RefreshFeed event does not call showBill`() = runTest { + createController() + + cashLinkDelegateEvents.emit(CashLinkDelegate.Event.RefreshFeed) + + advanceUntilIdle() + + verify(exactly = 0) { billDelegate.showBill(any()) } + } + + @Test + fun `GiftCardSharingDelegate RefreshFeed event does not call dismissBill or awaitBillGrab`() = runTest { + createController() + + giftCardDelegateEvents.emit(GiftCardSharingDelegate.Event.RefreshFeed) + + advanceUntilIdle() + + verify(exactly = 0) { billDelegate.dismissBill(any()) } + verify(exactly = 0) { billDelegate.awaitBillGrab(any(), any()) } + } + + // ------------------------------------------------------------------------- + // Multiple events: each BillReady from a different delegate routes correctly + // ------------------------------------------------------------------------- + + @Test + fun `multiple BillReady events from different delegates each call showBill once`() = runTest { + createController() + + val billFromScan = mockk(relaxed = true) + val billFromLink = mockk(relaxed = true) + + scanDelegateEvents.emit(CodeScanDelegate.Event.BillReady(billFromScan)) + cashLinkDelegateEvents.emit(CashLinkDelegate.Event.BillReady(billFromLink)) + + advanceUntilIdle() + + verify(exactly = 1) { billDelegate.showBill(billFromScan) } + verify(exactly = 1) { billDelegate.showBill(billFromLink) } + verify(exactly = 2) { billDelegate.showBill(any()) } + } +} diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index 3a53e19fa..c4dec1126 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -1,16 +1,20 @@ package com.flipcash.app.session.internal import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.core.MainCoroutineRule -import kotlinx.coroutines.test.TestCoroutineScheduler import com.flipcash.app.core.bill.Bill import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.session.internal.delegates.BillPresentationDelegate +import com.flipcash.app.session.internal.delegates.CashLinkDelegate +import com.flipcash.app.session.internal.delegates.CodeScanDelegate +import com.flipcash.app.session.internal.delegates.DepositDelegate +import com.flipcash.app.session.internal.delegates.GiftCardSharingDelegate import com.flipcash.app.shareable.ShareResult import com.flipcash.app.shareable.ShareSheetController import com.flipcash.app.shareable.Shareable import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider import com.flipcash.services.user.UserManager import com.flipcash.shared.session.R import com.getcode.manager.BottomBarManager @@ -28,6 +32,7 @@ import io.mockk.slot import io.mockk.unmockkObject import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher import kotlinx.coroutines.test.runTest import org.junit.After import org.junit.Before @@ -48,9 +53,8 @@ class SessionControllerGiftCardErrorTest { private val tokenCoordinator = mockk(relaxed = true) private val analytics = mockk(relaxed = true) private val networkObserver = mockk(relaxed = true) - private val accountCluster = mockk(relaxed = true) - private val dispatchers = TestDispatchers(TestCoroutineScheduler()) + private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()) @Before fun setUp() { @@ -68,31 +72,77 @@ class SessionControllerGiftCardErrorTest { private fun createController( shareSheetController: ShareSheetController = mockk(relaxed = true), ): RealSessionController { + val stateHolder = SessionStateHolder() + + val billDelegate = BillPresentationDelegate( + billController = billController, + stateHolder = stateHolder, + toastController = mockk(relaxed = true), + tokenCoordinator = tokenCoordinator, + analytics = analytics, + vibrator = mockk(relaxed = true), + resources = resources, + networkObserver = networkObserver, + userManager = userManager, + dispatchers = dispatchers, + ) + + val scanDelegate = CodeScanDelegate( + stateHolder = stateHolder, + billController = billController, + tokenCoordinator = tokenCoordinator, + analytics = analytics, + vibrator = mockk(relaxed = true), + userManager = userManager, + dispatchers = dispatchers, + ) + + val cashLinkDelegate = CashLinkDelegate( + stateHolder = stateHolder, + billController = billController, + tokenCoordinator = tokenCoordinator, + analytics = analytics, + resources = resources, + userManager = userManager, + ) + + val giftCardDelegate = GiftCardSharingDelegate( + billController = billController, + shareSheetController = shareSheetController, + shareConfirmationController = mockk(relaxed = true), + toastController = mockk(relaxed = true), + tokenCoordinator = tokenCoordinator, + transactionController = mockk(relaxed = true), + analytics = analytics, + vibrator = mockk(relaxed = true), + resources = resources, + dispatchers = dispatchers, + ) + return RealSessionController( + billDelegate = billDelegate, + scanDelegate = scanDelegate, + cashLinkDelegate = cashLinkDelegate, + depositDelegate = mockk(relaxed = true), + giftCardDelegate = giftCardDelegate, + stateHolder = stateHolder, billController = billController, userManager = userManager, accountController = mockk(relaxed = true), settingsController = mockk(relaxed = true), feedCoordinator = mockk(relaxed = true), - transactionController = mockk(relaxed = true), networkObserver = networkObserver, - resources = resources, - vibrator = mockk(relaxed = true), tokenUpdater = mockk(relaxed = true), activityFeedUpdater = mockk(relaxed = true), profileUpdater = mockk(relaxed = true), shareSheetController = shareSheetController, - shareConfirmationController = mockk(relaxed = true), toastController = mockk(relaxed = true), billingClient = mockk(relaxed = true), tokenCoordinator = tokenCoordinator, contactCoordinator = mockk(relaxed = true), + chatCoordinator = mockk(relaxed = true), featureFlagController = mockk(relaxed = true), - analytics = analytics, - usdcSweep = mockk(relaxed = true), appSettingsCoordinator = mockk(relaxed = true), - chatCoordinator = mockk(relaxed = true), - purchaseMethodController = mockk(relaxed = true), dispatchers = dispatchers, ) } @@ -231,7 +281,7 @@ class SessionControllerGiftCardErrorTest { sendAction.action() // Advance test dispatcher so IO-dispatched coroutines execute - dispatchers.dispatcher.scheduler.advanceUntilIdle() + dispatchers.testDispatcher.scheduler.advanceUntilIdle() // The guard should ensure fundGiftCard is called exactly once, not twice verify(exactly = 1) { @@ -269,15 +319,6 @@ class SessionControllerGiftCardErrorTest { onErrorSlot.captured.invoke(RuntimeException("funding failed")) } - // initiateGiftCardFunding is a private suspend function called from shareGiftCard - // which itself is called from the bill presentation flow. - // Since we can't easily reach it through the public API without complex bill state setup, - // we test the error callback behavior directly by verifying the BottomBarManager pattern. - // The onError lambda in initiateGiftCardFunding calls: - // dismissBill(PutInWallet) - // BottomBarManager.showError(title, message) - // cont.resume(Result.failure(it)) - // Simulate what the onError callback does: BottomBarManager.showError( title = resources.getString(R.string.error_title_failedToCreateGiftCard), diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt new file mode 100644 index 000000000..fee75dd09 --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt @@ -0,0 +1,62 @@ +package com.flipcash.app.session.internal + +import app.cash.turbine.test +import com.flipcash.app.session.SessionState +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue +import kotlinx.coroutines.test.runTest + +class SessionStateHolderTest { + + private fun holder() = SessionStateHolder() + + @Test + fun `initial state is default SessionState`() { + val holder = holder() + assertEquals(SessionState(), holder.state.value) + } + + @Test + fun `current returns snapshot of state`() { + val holder = holder() + assertEquals(SessionState(), holder.current) + } + + @Test + fun `update transforms state atomically`() { + val holder = holder() + holder.update { it.copy(vibrateOnScan = true) } + assertTrue(holder.state.value.vibrateOnScan) + } + + @Test + fun `multiple updates compose correctly`() { + val holder = holder() + holder.update { it.copy(vibrateOnScan = true) } + holder.update { it.copy(showNetworkOffline = true) } + val state = holder.state.value + assertTrue(state.vibrateOnScan) + assertTrue(state.showNetworkOffline) + } + + @Test + fun `reset returns to default state`() { + val holder = holder() + holder.update { it.copy(vibrateOnScan = true, hasGiveableBalance = true) } + holder.reset() + assertEquals(SessionState(), holder.state.value) + } + + @Test + fun `state is observable via Flow`() = runTest { + val holder = holder() + holder.state.test { + assertEquals(SessionState(), awaitItem()) + holder.update { it.copy(vibrateOnScan = true) } + val updated = awaitItem() + assertTrue(updated.vibrateOnScan) + cancelAndIgnoreRemainingEvents() + } + } +} diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt new file mode 100644 index 000000000..7d5fefe43 --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt @@ -0,0 +1,345 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.bill.BillState +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.session.PutInWallet +import com.flipcash.app.session.internal.SessionStateHolder +import com.flipcash.app.session.internal.toast.SessionToastController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.core.R +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.vibration.Vibrator +import com.getcode.utils.network.NetworkConnectivityListener +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class BillPresentationDelegateTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private val billController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val networkObserver = mockk(relaxed = true) + private val vibrator = mockk(relaxed = true) + private val toastController = mockk(relaxed = true) + private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()) + + private val accountCluster = mockk(relaxed = true) + + private fun createDelegate(stateHolder: SessionStateHolder = SessionStateHolder()): BillPresentationDelegate { + return BillPresentationDelegate( + billController = billController, + stateHolder = stateHolder, + toastController = toastController, + tokenCoordinator = tokenCoordinator, + analytics = analytics, + vibrator = vibrator, + resources = resources, + networkObserver = networkObserver, + userManager = userManager, + dispatchers = dispatchers, + ) + } + + @Before + fun setUp() { + BottomBarManager.clear() + every { userManager.accountCluster } returns accountCluster + every { networkObserver.isConnected } returns true + every { billController.state } returns mockk { + every { value } returns BillState.Default + } + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + // --- showBill guards --- + + @Test + fun `showBill with zero amount does nothing`() = runTest { + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 0.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + ) + + val delegate = createDelegate() + delegate.showBill(bill) + + verify(exactly = 0) { billController.update(any()) } + } + + @Test + fun `showBill without account cluster does nothing`() = runTest { + every { userManager.accountCluster } returns null + + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 5.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + ) + + val delegate = createDelegate() + delegate.showBill(bill) + + verify(exactly = 0) { billController.update(any()) } + } + + @Test + fun `showBill without network does not update billController`() = runTest { + every { networkObserver.isConnected } returns false + + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 5.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + ) + + val delegate = createDelegate() + delegate.showBill(bill) + + verify(exactly = 0) { billController.update(any()) } + } + + // --- showBill action configuration --- + + @Test + fun `showBill with airdrop sets null actions`() = runTest { + val updateSlot = slot<(BillState) -> BillState>() + every { billController.update(capture(updateSlot)) } answers {} + + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 5.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.airdrop, + ) + + val delegate = createDelegate() + delegate.showBill(bill) + + val updatedState = updateSlot.captured(BillState.Default) + assertNull(updatedState.primaryAction) + assertNull(updatedState.secondaryAction) + } + + @Test + fun `showBill with received cash sets null actions`() = runTest { + val updateSlot = slot<(BillState) -> BillState>() + every { billController.update(capture(updateSlot)) } answers {} + + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 5.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = true, + kind = Bill.Kind.cash, + ) + + val delegate = createDelegate() + delegate.showBill(bill) + + val updatedState = updateSlot.captured(BillState.Default) + assertNull(updatedState.primaryAction) + assertNull(updatedState.secondaryAction) + } + + @Test + fun `showBill with outgoing cash sets SendAsLink and Cancel actions`() = runTest { + val updateSlot = slot<(BillState) -> BillState>() + every { billController.update(capture(updateSlot)) } answers {} + + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 5.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + ) + + val delegate = createDelegate() + delegate.showBill(bill) + + val updatedState = updateSlot.captured(BillState.Default) + assertIs(updatedState.primaryAction) + assertIs(updatedState.secondaryAction) + } + + // --- awaitGrab is called --- + + @Test + fun `showBill with outgoing cash starts awaitGrab`() = runTest { + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 5.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + ) + + val delegate = createDelegate() + delegate.showBill(bill) + + verify(exactly = 1) { + billController.awaitGrab( + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + nonce = any(), + present = any(), + onGrabbed = any(), + onTimeout = any(), + onError = any(), + ) + } + } + + // --- dismissBill --- + + @Test + fun `dismissBill updates state and resets billController`() = runTest { + val stateHolder = SessionStateHolder() + val delegate = createDelegate(stateHolder) + + delegate.dismissBill(PutInWallet) + + assertEquals(PutInWallet, stateHolder.current.billResult) + verify(exactly = 1) { billController.reset() } + } + + // --- awaitBillGrab callbacks --- + + @Test + fun `awaitBillGrab onTimeout dismisses bill`() = runTest { + val onTimeoutSlot = slot<() -> Unit>() + every { + billController.awaitGrab( + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + nonce = any(), + present = any(), + onGrabbed = any(), + onTimeout = capture(onTimeoutSlot), + onError = any(), + ) + } answers {} + + val stateHolder = SessionStateHolder() + val delegate = createDelegate(stateHolder) + + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 3.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + ) + + delegate.awaitBillGrab(bill, accountCluster) + + onTimeoutSlot.captured.invoke() + + assertEquals(PutInWallet, stateHolder.current.billResult) + verify(exactly = 1) { billController.reset() } + } + + @Test + fun `awaitBillGrab onError dismisses bill and shows error`() = runTest { + every { resources.getString(R.string.error_title_CashReturnedToWallet) } returns "error_title_CashReturnedToWallet" + every { resources.getString(R.string.error_description_CashReturnedToWallet) } returns "error_description_CashReturnedToWallet" + + val onErrorSlot = slot<(Throwable) -> Unit>() + every { + billController.awaitGrab( + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + nonce = any(), + present = any(), + onGrabbed = any(), + onTimeout = any(), + onError = capture(onErrorSlot), + ) + } answers {} + + val stateHolder = SessionStateHolder() + val delegate = createDelegate(stateHolder) + + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 3.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + ) + + delegate.awaitBillGrab(bill, accountCluster) + + onErrorSlot.captured.invoke(RuntimeException("give failed")) + + assertEquals(PutInWallet, stateHolder.current.billResult) + verify(exactly = 1) { billController.reset() } + + val messages = BottomBarManager.messages.value + assertTrue(messages.isNotEmpty(), "Expected an error message in BottomBarManager") + assertEquals("error_title_CashReturnedToWallet", messages.first().title) + } +} diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt new file mode 100644 index 000000000..ced5d3fbc --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt @@ -0,0 +1,195 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.session.internal.SessionStateHolder +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.util.resources.ResourceHelper +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertFalse + +@OptIn(ExperimentalCoroutinesApi::class) +class CashLinkDelegateTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private val billController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + + private val accountCluster = mockk(relaxed = true) + + private fun createDelegate(): CashLinkDelegate { + return CashLinkDelegate( + stateHolder = SessionStateHolder(), + billController = billController, + tokenCoordinator = tokenCoordinator, + analytics = analytics, + resources = resources, + userManager = userManager, + ) + } + + @Before + fun setUp() { + BottomBarManager.clear() + every { userManager.accountCluster } returns accountCluster + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + @Test + fun `openCashLink with null input does not claim`() = runTest { + val delegate = createDelegate() + delegate.openCashLink(null) + + verify(exactly = 0) { + billController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = any(), + ) + } + verify { + analytics.deeplinkRouted(any(), error = any()) + } + } + + @Test + fun `openCashLink with empty string does not claim`() = runTest { + val delegate = createDelegate() + delegate.openCashLink("") + + verify(exactly = 0) { + billController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = any(), + ) + } + } + + @Test + fun `openCashLink with blank or newline-only input does not claim`() = runTest { + val delegate = createDelegate() + delegate.openCashLink(" \n ") + + verify(exactly = 0) { + billController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = any(), + ) + } + } + + @Test + fun `openCashLink without account cluster does not claim`() = runTest { + every { userManager.accountCluster } returns null + + val delegate = createDelegate() + delegate.openCashLink("validEntropy123") + + verify(exactly = 0) { + billController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = any(), + ) + } + verify { + analytics.deeplinkRouted(any(), error = any()) + } + } + + @Test + fun `openCashLink with valid entropy calls receiveGiftCard`() = runTest { + val delegate = createDelegate() + delegate.openCashLink("validEntropy123") + + verify(exactly = 1) { + billController.receiveGiftCard( + entropy = "validEntropy123", + owner = accountCluster, + claimIfOwned = false, + onReceived = any(), + onError = any(), + ) + } + } + + @Test + fun `openCashLink deduplicates concurrent claims`() = runTest { + val delegate = createDelegate() + + // First call starts the claim + delegate.openCashLink("sameEntropy") + + // Second call while claim is in progress should be ignored + delegate.openCashLink("sameEntropy") + + verify(exactly = 1) { + billController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = any(), + ) + } + } + + @Test + fun `openCashLink trims whitespace and newlines from entropy`() = runTest { + val delegate = createDelegate() + delegate.openCashLink(" some\nentropy ") + + verify { + billController.receiveGiftCard( + entropy = "someentropy", + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = any(), + ) + } + } + + @Test + fun `openCashLink clears bottom bar before processing`() = runTest { + // Add a message to BottomBarManager first + BottomBarManager.showInfo(title = "existing", message = "message") + assertFalse(BottomBarManager.messages.value.isEmpty()) + + val delegate = createDelegate() + delegate.openCashLink(null) // even on early return, bottom bar should be cleared + + assert(BottomBarManager.messages.value.isEmpty()) + } +} diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt new file mode 100644 index 000000000..9bf90145a --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt @@ -0,0 +1,258 @@ +package com.flipcash.app.session.internal.delegates + +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.bill.BillState +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.session.internal.SessionStateHolder +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.core.OpenCodePayload +import com.getcode.opencode.model.core.PayloadKind +import com.getcode.util.vibration.Vibrator +import com.kik.kikx.models.ScannableKikCode +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@OptIn(ExperimentalCoroutinesApi::class) +class CodeScanDelegateTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private val billController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val vibrator = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()) + + private val stateHolder = SessionStateHolder() + + private val mockPayload = mockk(relaxed = true) { + every { kind } returns PayloadKind.Cash + every { rendezvous.publicKey } returns "test-rendezvous-key" + } + + private fun createDelegate(): CodeScanDelegate { + return CodeScanDelegate( + stateHolder = stateHolder, + billController = billController, + tokenCoordinator = tokenCoordinator, + analytics = analytics, + vibrator = vibrator, + userManager = userManager, + dispatchers = dispatchers, + ) + } + + @Before + fun setUp() { + every { userManager.accountCluster } returns mockk(relaxed = true) + every { billController.state } returns mockk { + every { value } returns BillState.Default + } + mockkObject(OpenCodePayload.Companion) + every { OpenCodePayload.fromList(any()) } returns mockPayload + } + + @After + fun tearDown() { + unmockkObject(OpenCodePayload.Companion) + } + + private fun remoteKikCode(payloadId: ByteArray = ByteArray(20)): ScannableKikCode { + return ScannableKikCode.RemoteKikCode(payloadId = payloadId, colorIndex = 0) + } + + // --- onCameraScanning --- + + @Test + fun `onCameraScanning updates state holder`() = runTest { + val delegate = createDelegate() + assertNull(stateHolder.current.isCameraUp) + + delegate.onCameraScanning(true) + assertEquals(true, stateHolder.current.isCameraUp) + + delegate.onCameraScanning(false) + assertEquals(false, stateHolder.current.isCameraUp) + } + + // --- onCodeScan guards --- + + @Test + fun `onCodeScan ignores non-RemoteKikCode`() = runTest { + val delegate = createDelegate() + val nonRemote = mockk(relaxed = true) + delegate.onCodeScan(nonRemote) + + verify(exactly = 0) { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = any(), + onError = any(), + ) + } + } + + @Test + fun `onCodeScan ignores scan when bill is already showing`() = runTest { + every { billController.state } returns mockk { + every { value } returns BillState.Default.copy(bill = mockk(relaxed = true)) + } + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + + verify(exactly = 0) { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = any(), + onError = any(), + ) + } + } + + @Test + fun `onCodeScan deduplicates same rendezvous key`() = runTest { + val delegate = createDelegate() + + delegate.onCodeScan(remoteKikCode()) + delegate.onCodeScan(remoteKikCode()) // same rendezvous key + + verify(exactly = 1) { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = any(), + onError = any(), + ) + } + } + + @Test + fun `onCodeScan ignores Unknown payload kind`() = runTest { + every { mockPayload.kind } returns PayloadKind.Unknown + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + + verify(exactly = 0) { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = any(), + onError = any(), + ) + } + } + + // --- vibrateOnScan --- + + @Test + fun `onCodeScan vibrates when vibrateOnScan is enabled`() = runTest { + stateHolder.update { it.copy(vibrateOnScan = true) } + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + + verify { vibrator.tick() } + } + + @Test + fun `onCodeScan does not vibrate when vibrateOnScan is disabled`() = runTest { + stateHolder.update { it.copy(vibrateOnScan = false) } + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + + verify(exactly = 0) { vibrator.tick() } + } + + // --- Cash scan triggers attemptGrab --- + + @Test + fun `onCodeScan with Cash kind calls attemptGrab`() = runTest { + every { mockPayload.kind } returns PayloadKind.Cash + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + + verify(exactly = 1) { + billController.attemptGrab( + owner = any(), + payload = mockPayload, + onGrabbed = any(), + onError = any(), + ) + } + } + + @Test + fun `onCodeScan with MultiMintCash kind calls attemptGrab`() = runTest { + every { mockPayload.kind } returns PayloadKind.MultiMintCash + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + + verify(exactly = 1) { + billController.attemptGrab( + owner = any(), + payload = mockPayload, + onGrabbed = any(), + onError = any(), + ) + } + } + + // --- Error clears rendezvous so re-scan is possible --- + + @Test + fun `attemptGrab error clears rendezvous allowing re-scan`() = runTest { + val onErrorSlot = mutableListOf<(Throwable) -> Unit>() + every { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = any(), + onError = capture(onErrorSlot), + ) + } answers {} + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + + // Invoke the error callback + assertTrue(onErrorSlot.isNotEmpty()) + onErrorSlot.first().invoke(RuntimeException("grab failed")) + + // Should be able to scan the same code again + delegate.onCodeScan(remoteKikCode()) + + verify(exactly = 2) { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = any(), + onError = any(), + ) + } + } +} diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt new file mode 100644 index 000000000..7ca6454f8 --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt @@ -0,0 +1,268 @@ +package com.flipcash.app.session.internal.delegates + +import app.cash.turbine.test +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.bill.BillState +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.session.internal.SessionStateHolder +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.user.UserManager +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.OpenCodePayload +import com.getcode.opencode.model.core.PayloadKind +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import com.getcode.utils.network.NetworkConnectivityListener +import com.kik.kikx.models.ScannableKikCode +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.slot +import io.mockk.unmockkObject +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class DelegateEventEmissionTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private val billController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val networkObserver = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()) + + private val accountCluster = mockk(relaxed = true) + + @Before + fun setUp() { + every { userManager.accountCluster } returns accountCluster + every { networkObserver.isConnected } returns true + every { billController.state } returns mockk { + every { value } returns BillState.Default + } + } + + @After + fun tearDown() {} + + // --- BillPresentationDelegate events --- + + @Test + fun `BillPresentationDelegate emits SendAsLinkRequested when send-as-link action invoked`() = runTest { + val stateHolder = SessionStateHolder() + val delegate = BillPresentationDelegate( + billController = billController, + stateHolder = stateHolder, + toastController = mockk(relaxed = true), + tokenCoordinator = tokenCoordinator, + analytics = analytics, + vibrator = mockk(relaxed = true), + resources = resources, + networkObserver = networkObserver, + userManager = userManager, + dispatchers = dispatchers, + ) + + val updateSlot = slot<(BillState) -> BillState>() + every { billController.update(capture(updateSlot)) } answers {} + + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 5.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + verifiedState = mockk(relaxed = true), + ) + + delegate.showBill(bill) + + // Extract the SendAsLink action and invoke it + val updatedState = updateSlot.captured(BillState.Default) + val sendAction = updatedState.primaryAction as BillState.Action.SendAsLink + + delegate.events.test { + sendAction.action() + val event = awaitItem() + assertIs(event) + assertEquals(bill, event.bill) + assertEquals(accountCluster, event.owner) + } + } + + @Test + fun `BillPresentationDelegate emits RefreshFeed on successful grab`() = runTest { + val stateHolder = SessionStateHolder() + val delegate = BillPresentationDelegate( + billController = billController, + stateHolder = stateHolder, + toastController = mockk(relaxed = true), + tokenCoordinator = tokenCoordinator, + analytics = analytics, + vibrator = mockk(relaxed = true), + resources = resources, + networkObserver = networkObserver, + userManager = userManager, + dispatchers = dispatchers, + ) + + val onGrabbedSlot = slot Unit>() + every { + billController.awaitGrab( + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + nonce = any(), + present = any(), + onGrabbed = capture(onGrabbedSlot), + onTimeout = any(), + onError = any(), + ) + } answers {} + + val amount = mockk(relaxed = true) { + every { nativeAmount.decimalValue } returns 3.0 + } + val bill = Bill.Cash( + token = mockk(relaxed = true), + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + ) + + delegate.awaitBillGrab(bill, accountCluster) + + delegate.events.test { + onGrabbedSlot.captured.invoke(amount) + val event = awaitItem() + assertIs(event) + } + } + + // --- CodeScanDelegate events --- + + @Test + fun `CodeScanDelegate emits BillReady and feed events on successful grab`() = runTest { + mockkObject(OpenCodePayload.Companion) + val mockPayload = mockk(relaxed = true) { + every { kind } returns PayloadKind.Cash + every { rendezvous.publicKey } returns "scan-event-test-key" + } + every { OpenCodePayload.fromList(any()) } returns mockPayload + + try { + val stateHolder = SessionStateHolder() + val delegate = CodeScanDelegate( + stateHolder = stateHolder, + billController = billController, + tokenCoordinator = tokenCoordinator, + analytics = analytics, + vibrator = mockk(relaxed = true), + userManager = userManager, + dispatchers = dispatchers, + ) + + val onGrabbedSlot = slot Unit>() + every { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = capture(onGrabbedSlot), + onError = any(), + ) + } answers {} + + val code = ScannableKikCode.RemoteKikCode( + payloadId = ByteArray(20), + colorIndex = 0 + ) + delegate.onCodeScan(code) + + val token = mockk(relaxed = true) + val amount = mockk(relaxed = true) + + delegate.events.test { + onGrabbedSlot.captured.invoke(token, amount, null) + + val billEvent = awaitItem() + assertIs(billEvent) + + val checkEvent = awaitItem() + assertIs(checkEvent) + + val refreshEvent = awaitItem() + assertIs(refreshEvent) + } + } finally { + unmockkObject(OpenCodePayload.Companion) + } + } + + // --- CashLinkDelegate events --- + + @Suppress("UNCHECKED_CAST") + @Test + fun `CashLinkDelegate emits BillReady and feed events on successful claim`() = runTest { + val stateHolder = SessionStateHolder() + val localBillController = mockk(relaxed = true) + val delegate = CashLinkDelegate( + stateHolder = stateHolder, + billController = localBillController, + tokenCoordinator = tokenCoordinator, + analytics = analytics, + resources = resources, + userManager = userManager, + ) + + val token = mockk(relaxed = true) + val amount = mockk(relaxed = true) + + // Use answers + invocation args to work around MockK suspend lambda capture issues + every { + localBillController.receiveGiftCard( + entropy = any(), + owner = any(), + claimIfOwned = any(), + onReceived = any(), + onError = any(), + ) + } answers { + val onReceived = args[3] as suspend (Token, LocalFiat) -> Unit + runBlocking { onReceived(token, amount) } + } + + delegate.events.test { + delegate.openCashLink("validEntropy") + + val billEvent = awaitItem() + assertIs(billEvent) + + val checkEvent = awaitItem() + assertIs(checkEvent) + + val refreshEvent = awaitItem() + assertIs(refreshEvent) + } + } +} diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt new file mode 100644 index 000000000..01f7fe0be --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt @@ -0,0 +1,321 @@ +package com.flipcash.app.session.internal.delegates + +import app.cash.turbine.test +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.bill.Bill +import com.flipcash.app.core.bill.BillState +import com.flipcash.app.core.internal.bill.BillController +import com.flipcash.app.session.PutInWallet +import com.flipcash.core.R +import com.flipcash.app.session.internal.toast.SessionToastController +import com.flipcash.app.shareable.ShareConfirmationResult +import com.flipcash.app.shareable.ShareResult +import com.flipcash.app.shareable.ShareSheetController +import com.flipcash.app.shareable.Shareable +import com.flipcash.app.shareable.ShareableConfirmationController +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.internal.manager.VerifiedState +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.accounts.GiftCardAccount +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Token +import com.getcode.util.resources.ResourceHelper +import com.getcode.util.vibration.Vibrator +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.unmockkObject +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertIs + +@OptIn(ExperimentalCoroutinesApi::class) +class GiftCardSharingDelegateTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private val billController = mockk(relaxed = true) + private val shareConfirmationController = mockk(relaxed = true) + private val toastController = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) + private val vibrator = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val accountCluster = mockk(relaxed = true) + private val verifiedState = mockk(relaxed = true) + private val amount = mockk(relaxed = true) + private val token = mockk(relaxed = true) + + private lateinit var shareSheetController: ShareSheetController + + @Before + fun setUp() { + BottomBarManager.clear() + + every { billController.state } returns mockk { + every { value } returns BillState.Default + } + + every { resources.getString(R.string.action_yes) } returns "Yes" + every { resources.getString(R.string.action_nevermind) } returns "Nevermind" + + mockkObject(GiftCardAccount.Companion) + every { GiftCardAccount.create(any(), any()) } returns mockk(relaxed = true) + + shareSheetController = object : ShareSheetController { + override val isCheckingForShare: Boolean = false + override var onShared: ((ShareResult) -> Unit)? = null + override fun checkForShare() {} + override suspend fun present(shareable: Shareable) {} + override fun reset(setChecked: Boolean) {} + } + } + + @After + fun tearDown() { + BottomBarManager.clear() + unmockkObject(GiftCardAccount.Companion) + } + + private fun TestScope.createDelegate( + shareSheet: ShareSheetController = shareSheetController, + ): GiftCardSharingDelegate { + return GiftCardSharingDelegate( + billController = billController, + shareSheetController = shareSheet, + shareConfirmationController = shareConfirmationController, + toastController = toastController, + tokenCoordinator = tokenCoordinator, + transactionController = transactionController, + analytics = analytics, + vibrator = vibrator, + resources = resources, + dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher(testScheduler)), + ) + } + + private fun makeBill(): Bill.Cash = Bill.Cash( + token = token, + amount = amount, + didReceive = false, + kind = Bill.Kind.cash, + verifiedState = verifiedState, + ) + + // ------------------------------------------------------------------------- + // Test 1: NotShared emits RestartBillGrab + // ------------------------------------------------------------------------- + + @Test + fun `shareGiftCard NotShared emits RestartBillGrab`() = runTest { + val bill = makeBill() + val delegate = createDelegate() + + delegate.events.test { + delegate.shareGiftCard(bill, accountCluster) + // onShared is assigned and present() was called synchronously via UnconfinedTestDispatcher + shareSheetController.onShared?.invoke(ShareResult.NotShared) + + val event = awaitItem() + assertIs(event) + } + } + + // ------------------------------------------------------------------------- + // Test 2: funding error emits DismissBill(PutInWallet) + // ------------------------------------------------------------------------- + + @Suppress("UNCHECKED_CAST") + @Test + fun `shareGiftCard funding error emits DismissBill PutInWallet`() = runTest { + every { + billController.fundGiftCard( + giftCard = any(), + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + onFunded = any(), + onError = any(), + ) + } answers { + val onError = args[6] as (Throwable) -> Unit + onError(RuntimeException("funding failed")) + } + + val bill = makeBill() + val delegate = createDelegate() + + delegate.events.test { + delegate.shareGiftCard(bill, accountCluster) + // Trigger the share action so the funding path is entered + shareSheetController.onShared?.invoke(ShareResult.CopiedToClipboard) + + val event = awaitItem() + assertIs(event) + assertIs(event.action) + } + } + + // ------------------------------------------------------------------------- + // Test 3: cancelGiftCard success emits DismissBill(PutInWallet) and calls tokenCoordinator.update() + // ------------------------------------------------------------------------- + + @Test + fun `cancelGiftCard success emits DismissBill PutInWallet and updates tokenCoordinator`() = runTest { + coEvery { + transactionController.cancelRemoteSend( + vault = any(), + owner = any(), + ) + } returns Result.success(Unit) + + @Suppress("UNCHECKED_CAST") + every { + billController.fundGiftCard( + giftCard = any(), + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + onFunded = any(), + onError = any(), + ) + } answers { + val onFunded = args[5] as suspend (LocalFiat) -> Unit + runBlocking { onFunded(amount) } + } + + coEvery { + shareConfirmationController.confirm(any(), any()) + } returns ShareConfirmationResult.Cancelled + + val bill = makeBill() + val delegate = createDelegate() + + delegate.events.test { + delegate.shareGiftCard(bill, accountCluster) + advanceUntilIdle() + shareSheetController.onShared?.invoke(ShareResult.CopiedToClipboard) + advanceUntilIdle() + + val messages = BottomBarManager.messages.value + val yesAction = messages.firstOrNull()?.actions?.firstOrNull { it.text.toString() == "Yes" } + yesAction?.onClick?.invoke() + advanceUntilIdle() + + val event = awaitItem() + assertIs(event) + assertIs(event.action) + } + + coVerify { tokenCoordinator.update() } + } + + // ------------------------------------------------------------------------- + // Test 4: cancelGiftCard failure emits DismissBill(PutInWallet) + // ------------------------------------------------------------------------- + + @Test + fun `cancelGiftCard failure emits DismissBill PutInWallet`() = runTest { + coEvery { + transactionController.cancelRemoteSend( + vault = any(), + owner = any(), + ) + } returns Result.failure(RuntimeException("cancel failed")) + + @Suppress("UNCHECKED_CAST") + every { + billController.fundGiftCard( + giftCard = any(), + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + onFunded = any(), + onError = any(), + ) + } answers { + val onFunded = args[5] as suspend (LocalFiat) -> Unit + runBlocking { onFunded(amount) } + } + + coEvery { + shareConfirmationController.confirm(any(), any()) + } returns ShareConfirmationResult.Cancelled + + val bill = makeBill() + val delegate = createDelegate() + + delegate.events.test { + delegate.shareGiftCard(bill, accountCluster) + advanceUntilIdle() + shareSheetController.onShared?.invoke(ShareResult.CopiedToClipboard) + advanceUntilIdle() + + val messages = BottomBarManager.messages.value + val yesAction = messages.firstOrNull()?.actions?.firstOrNull { it.text.toString() == "Yes" } + yesAction?.onClick?.invoke() + advanceUntilIdle() + + val event = awaitItem() + assertIs(event) + assertIs(event.action) + } + } + + // ------------------------------------------------------------------------- + // Test 5: double funding is guarded by AtomicBoolean + // ------------------------------------------------------------------------- + + @Test + fun `double funding is guarded by AtomicBoolean`() = runTest { + val bill = makeBill() + + // Use a share sheet that fires onShared twice on present(), simulating the race + // between shareResultReceiver and checkForShare(). + val racingShareSheet = object : ShareSheetController { + override val isCheckingForShare: Boolean = false + override var onShared: ((ShareResult) -> Unit)? = null + override fun checkForShare() {} + override suspend fun present(shareable: Shareable) { + onShared?.invoke(ShareResult.CopiedToClipboard) + onShared?.invoke(ShareResult.CopiedToClipboard) + } + override fun reset(setChecked: Boolean) {} + } + + val delegate = createDelegate(shareSheet = racingShareSheet) + delegate.shareGiftCard(bill, accountCluster) + + verify(exactly = 1) { + billController.fundGiftCard( + giftCard = any(), + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + onFunded = any(), + onError = any(), + ) + } + } +} diff --git a/buildSrc/build.gradle.kts b/buildSrc/build.gradle.kts index a1e8f8452..43ae974ee 100644 --- a/buildSrc/build.gradle.kts +++ b/buildSrc/build.gradle.kts @@ -7,3 +7,7 @@ repositories { mavenCentral() gradlePluginPortal() } + +dependencies { + implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.11.0") +} diff --git a/buildSrc/src/main/java/GenerateCurveTables.kt b/buildSrc/src/main/java/GenerateCurveTables.kt index b6b0b4ff5..44ad6a729 100644 --- a/buildSrc/src/main/java/GenerateCurveTables.kt +++ b/buildSrc/src/main/java/GenerateCurveTables.kt @@ -24,12 +24,16 @@ abstract class GenerateCurveTables : DefaultTask() { @get:Input abstract val forceRegenerate: Property + @get:Input + abstract val forceViaGradleProperty: Property + init { // Default to false forceRegenerate.convention(false) + forceViaGradleProperty.convention(false) onlyIf { - val force = project.hasProperty("forceCurveTables") || forceRegenerate.get() + val force = forceViaGradleProperty.get() || forceRegenerate.get() if (force) { true diff --git a/buildSrc/src/main/java/GenerateEmojiList.kt b/buildSrc/src/main/java/GenerateEmojiList.kt new file mode 100644 index 000000000..0af789123 --- /dev/null +++ b/buildSrc/src/main/java/GenerateEmojiList.kt @@ -0,0 +1,227 @@ +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import org.gradle.api.DefaultTask +import org.gradle.api.file.DirectoryProperty +import org.gradle.api.provider.Property +import org.gradle.api.tasks.Input +import org.gradle.api.tasks.Internal +import org.gradle.api.tasks.OutputDirectory +import org.gradle.api.tasks.TaskAction +import java.io.File +import java.net.URI +import java.nio.file.Files +import java.nio.file.StandardCopyOption + +abstract class GenerateEmojiList : DefaultTask() { + + @get:Input + abstract val emojiUrl: Property + + @get:Input + abstract val emojiKeywordsUrl: Property + + @get:Internal + abstract val emojiCacheDir: DirectoryProperty + + @get:OutputDirectory + abstract val outputDir: DirectoryProperty + + init { + description = "Fetches Unicode emoji list and generates categorized Kotlin source file if needed" + group = "emoji" + + onlyIf { + !outputDir.get().asFile.resolve("Emojis.kt").exists() + } + } + + @TaskAction + fun generate() { + val outDir = outputDir.get().asFile + val cacheDir = emojiCacheDir.get().asFile + val emojiFile = File(cacheDir, "emoji-test.txt") + val keywordsFile = File(cacheDir, "en-keywords.json") + + outDir.mkdirs() + if (!emojiFile.exists()) { + logger.lifecycle("Downloading emoji-test.txt") + URI(emojiUrl.get()).toURL().openStream().use { input -> + Files.copy(input, emojiFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } + + if (!keywordsFile.exists()) { + logger.lifecycle("Downloading CLDR annotations") + URI(emojiKeywordsUrl.get()).toURL().openStream().use { input -> + Files.copy(input, keywordsFile.toPath(), StandardCopyOption.REPLACE_EXISTING) + } + } + + val json = Json { ignoreUnknownKeys = true } + val cldrData = json.parseToJsonElement(keywordsFile.readText()).jsonObject + val cldrAnnotations = cldrData["annotations"]?.jsonObject?.get("annotations")?.jsonObject + + val emojiText = emojiFile.readText() + val emojiCategories = + mutableMapOf>>>() + val emojiCategoriesNoSkinTones = + mutableMapOf>>>() + var currentGroup = "Uncategorized" + var currentSubgroup = "Uncategorized" + + emojiText.lines().forEach { line -> + when { + line.startsWith("# group:") -> { + val groupName = line.removePrefix("# group:").trim() + currentGroup = when (groupName) { + "Smileys & Emotion", "People & Body" -> "Smileys & People" + else -> groupName + } + emojiCategories.getOrPut(currentGroup) { mutableMapOf() } + emojiCategoriesNoSkinTones.getOrPut(currentGroup) { mutableMapOf() } + } + + line.startsWith("# subgroup:") -> { + currentSubgroup = line.removePrefix("# subgroup:").trim() + emojiCategories[currentGroup]?.getOrPut(currentSubgroup) { mutableListOf() } + emojiCategoriesNoSkinTones[currentGroup]?.getOrPut(currentSubgroup) { mutableListOf() } + } + + line.isNotBlank() && !line.startsWith("#") -> { + val parts = line.split(";").map { it.trim() } + if (parts.size > 1 && parts[1].contains("fully-qualified")) { + val codePoints = parts[0].split(" ").map { it.toInt(16) } + val unicode = codePoints.map { codePoint -> + if (codePoint <= 0xFFFF) { + codePoint.toChar().toString() + } else { + String(Character.toChars(codePoint)) + } + }.joinToString("") + val nameParts = line.split("#")[1].trim().split(" ") + val name = nameParts.drop(2).joinToString(" ") + val baseKeywords = name.split(" ").filter { it.isNotBlank() } + + val cldrAnnotation = cldrAnnotations?.get(unicode)?.jsonObject + val cldrKeywords = cldrAnnotation?.get("default")?.jsonArray + ?.mapNotNull { it.jsonPrimitive.toString().removeSurrounding("\"") } + .orEmpty() + val allKeywords = (baseKeywords + cldrKeywords).distinct() + + val emojiEntry = mutableMapOf( + "unicode" to unicode, + "name" to name, + "keywords" to allKeywords + ) + emojiCategories[currentGroup]?.get(currentSubgroup)?.add(emojiEntry) + + val hasSkinTone = codePoints.any { it in 0x1F3FB..0x1F3FF } + if (!hasSkinTone) { + emojiCategoriesNoSkinTones[currentGroup]?.get(currentSubgroup) + ?.add(emojiEntry) + } + } + } + } + } + + val nonEmptyCategories = emojiCategories.filter { (_, subgroups) -> + subgroups.any { (_, emojis) -> emojis.isNotEmpty() } + } + val nonEmptyCategoriesNoSkinTones = + emojiCategoriesNoSkinTones.filter { (_, subgroups) -> + subgroups.any { (_, emojis) -> emojis.isNotEmpty() } + } + + val mainFile = File(outDir, "Emojis.kt") + val mainCode = buildString { + appendLine("// Generated file - Do not edit manually") + appendLine("package com.getcode.libs.emojis.generated") + appendLine() + appendLine("data class Emoji(") + appendLine(" val unicode: String,") + appendLine(" val name: String,") + appendLine(" val keywords: List") + appendLine(")") + appendLine() + appendLine("enum class Category(val displayName: String) {") + nonEmptyCategories.keys.forEach { group -> + val enumName = group.replace("[^A-Za-z0-9]".toRegex(), "").uppercase() + appendLine(" $enumName(\"$group\"),") + } + appendLine(" FREQUENT(\"Frequently Used\"),") + appendLine("}") + appendLine() + appendLine("object Emojis {") + appendLine(" val categorized = mapOf(") + nonEmptyCategories.forEach { (group, subgroups) -> + val enumName = group.replace("[^A-Za-z0-9]".toRegex(), "").uppercase() + appendLine(" Category.$enumName to mapOf(") + subgroups.forEach { (subgroup, _) -> + val safeGroupName = group.replace("[^A-Za-z0-9]".toRegex(), "") + val safeSubgroupName = subgroup.replace("[^A-Za-z0-9]".toRegex(), "") + appendLine(" \"$subgroup\" to ${safeGroupName}${safeSubgroupName}Emojis.categorized,") + } + appendLine(" ),") + } + appendLine(" )") + appendLine() + appendLine(" val categorizedNoSkinTones = mapOf(") + nonEmptyCategoriesNoSkinTones.forEach { (group, subgroups) -> + val enumName = group.replace("[^A-Za-z0-9]".toRegex(), "").uppercase() + appendLine(" Category.$enumName to mapOf(") + subgroups.forEach { (subgroup, _) -> + val safeGroupName = group.replace("[^A-Za-z0-9]".toRegex(), "") + val safeSubgroupName = subgroup.replace("[^A-Za-z0-9]".toRegex(), "") + appendLine(" \"$subgroup\" to ${safeGroupName}${safeSubgroupName}Emojis.categorizedNoSkinTones,") + } + appendLine(" ),") + } + appendLine(" )") + appendLine("}") + }.trimIndent() + mainFile.writeText(mainCode) + + emojiCategories.forEach { (group, subgroups) -> + subgroups.forEach { (subgroup, emojis) -> + val safeGroupName = group.replace("[^A-Za-z0-9]".toRegex(), "") + val safeSubgroupName = subgroup.replace("[^A-Za-z0-9]".toRegex(), "") + val subgroupFile = + File(outDir, "${safeGroupName}${safeSubgroupName}Emojis.kt") + @Suppress("UNCHECKED_CAST") + val subgroupCode = buildString { + appendLine("// Generated file - Do not edit manually") + appendLine("package com.getcode.libs.emojis.generated") + appendLine() + appendLine("object ${safeGroupName}${safeSubgroupName}Emojis {") + appendLine(" val categorized = ${if (emojis.isEmpty()) "emptyList()" else "listOf("}") + if (emojis.isNotEmpty()) { + appendLine(" ${emojis.joinToString(",\n ") { "Emoji(\"${it["unicode"]}\", \"${it["name"]}\", listOf(${(it["keywords"] as List).joinToString { "\"$it\"" }}))" }}") + appendLine(" )") + } + appendLine() + appendLine( + " val categorizedNoSkinTones = ${ + if (emojiCategoriesNoSkinTones[group]?.get( + subgroup + )?.isEmpty() != false + ) "emptyList()" else "listOf(" + }" + ) + val noSkinTones = + emojiCategoriesNoSkinTones[group]?.get(subgroup) ?: emptyList() + if (noSkinTones.isNotEmpty()) { + appendLine(" ${noSkinTones.joinToString(",\n ") { "Emoji(\"${it["unicode"]}\", \"${it["name"]}\", listOf(${(it["keywords"] as List).joinToString { "\"$it\"" }}))" }}") + appendLine(" )") + } + appendLine("}") + }.trimIndent() + subgroupFile.writeText(subgroupCode) + } + } + val totalEmojis = emojiCategories.values.sumOf { it.values.sumOf { e -> e.size } } + logger.lifecycle("Generated $totalEmojis emojis across ${emojiCategories.size} categories") + } +} diff --git a/docs/architecture/02-state-and-dependency-injection.md b/docs/architecture/02-state-and-dependency-injection.md index 252895309..861762e6c 100644 --- a/docs/architecture/02-state-and-dependency-injection.md +++ b/docs/architecture/02-state-and-dependency-injection.md @@ -131,6 +131,57 @@ domain API; a Coordinator is the stateful, session-aware owner of that domain's cached state.** When in doubt, [09 — Separation of concerns](09-separation-of-concerns.md) has a "where does this code go?" table. +## Delegate composition: `SessionController` + +The `SessionController` interface is split into four sub-interfaces — +`BillOperations`, `CodeScanOperations`, `CashLinkOperations`, `DepositOperations` — +plus lifecycle methods. `RealSessionController` implements each sub-interface via +Kotlin `by` delegation to a focused `@Singleton` delegate: + +```kotlin +class RealSessionController @Inject constructor( + private val billDelegate: BillPresentationDelegate, + private val scanDelegate: CodeScanDelegate, + private val cashLinkDelegate: CashLinkDelegate, + private val depositDelegate: DepositDelegate, + private val giftCardDelegate: GiftCardSharingDelegate, + private val stateHolder: SessionStateHolder, + // ... remaining deps for lifecycle/polling +) : SessionController, + BillOperations by billDelegate, + CodeScanOperations by scanDelegate, + CashLinkOperations by cashLinkDelegate, + DepositOperations by depositDelegate { ... } +``` + +Each delegate owns its own `CoroutineScope`, dependencies, and logic. Cross-delegate +communication uses event flows — each delegate exposes a `Flow` (backed by a +`Channel(UNLIMITED)` for guaranteed delivery), and +the shell's `init` block collects them and routes events: + +```kotlin +// In RealSessionController init: +billDelegate.events.onEach { event -> + when (event) { + is BillPresentationDelegate.Event.SendAsLinkRequested -> + giftCardDelegate.shareGiftCard(event.bill, event.owner) + is BillPresentationDelegate.Event.RefreshFeed -> + bringActivityFeedCurrent() + } +}.launchIn(scope) +``` + +A shared `SessionStateHolder` (wrapping `MutableStateFlow`) is injected +into every delegate so they all read/write the same session state without holding the +raw mutable flow. Lifecycle orchestration (`onAppInForeground`/`onAppInBackground`) and +flow observers (auth state, feature flags, network reconnects) remain on the shell +because they are inherently cross-cutting. + +The relevant source files live under `apps/flipcash/shared/session/.../internal/`: +`SessionStateHolder.kt`, and in the `delegates/` sub-package: +`BillPresentationDelegate.kt`, `CodeScanDelegate.kt`, `CashLinkDelegate.kt`, +`DepositDelegate.kt`, `GiftCardSharingDelegate.kt`. The shell is `RealSessionController.kt`. + ## State: `BaseViewModel` The MVI base class lives at diff --git a/docs/architecture/09-separation-of-concerns.md b/docs/architecture/09-separation-of-concerns.md index 1dcfe89d4..47f49a13f 100644 --- a/docs/architecture/09-separation-of-concerns.md +++ b/docs/architecture/09-separation-of-concerns.md @@ -41,6 +41,15 @@ Service — is defined in [02 — Roles](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services). Features never reach into each other's internals; they go through shared modules. +### 3a. Delegate composition for large controllers +When a shared controller grows beyond a manageable size, decompose it using Kotlin +`by` interface delegation: split the public interface into sub-interfaces, implement +each in a focused delegate class, and compose them in a thin orchestration shell. +Cross-delegate calls flow through `Channel`s (exposed as `Flow`) collected by the shell — no +circular references, no `lateinit`, no post-construction wiring. `SessionController` +is the canonical example (see +[02 — Delegate composition](02-state-and-dependency-injection.md#delegate-composition-sessioncontroller)). + ### 4. Transport details stop at the data layer The gRPC stack's four layers (Api → Service → Repository → Controller) mean protobuf types, channels, and signing never appear in a feature. Features consume @@ -69,6 +78,7 @@ feature code. See [06 — Payments & operations](06-payments-and-operations.md). | A new screen | a `:apps:flipcash:features:*` module (screen + ViewModel + Hilt module) | | Cached, synced state for a domain (contacts, tokens, chat) | a `:apps:flipcash:shared:*` **Coordinator** ([roles](02-state-and-dependency-injection.md#roles-coordinators-controllers-managers-services)) | | Stateless shared logic two+ features call | a `:apps:flipcash:shared:*` **Controller** | +| A large shared controller that has grown unwieldy | Decompose with Kotlin `by` delegation into focused delegates ([3a](#3a-delegate-composition-for-large-controllers)) | | A new backend call | the appropriate `:services:*` layer (Api → Service → Repository → Controller) | | A reusable component or token | `:ui:components` / `:ui:theme` | | A domain-agnostic utility | a `:libs:*` module | diff --git a/docs/architecture/12-testing.md b/docs/architecture/12-testing.md index a2b6b9019..d7916901d 100644 --- a/docs/architecture/12-testing.md +++ b/docs/architecture/12-testing.md @@ -117,6 +117,36 @@ bundle exec fastlane android flipcash_tests # what CI runs See [10 — Build & run](10-build-and-run.md) for the wider command set. +## Testing delegate event emissions + +Session delegates ([02 — Delegate composition](02-state-and-dependency-injection.md#delegate-composition-sessioncontroller)) +emit cross-delegate events via `Channel` (exposed as `Flow`). Tests assert on these events using +Turbine: + +```kotlin +@Test +fun `emits BillReady on successful grab`() = runTest { + val delegate = CodeScanDelegate( + stateHolder = SessionStateHolder(), + billController = mockk(relaxed = true), + // ... other mocked deps, DispatcherProvider with UnconfinedTestDispatcher + ) + // set up mock captures for onGrabbed callback ... + + delegate.events.test { + // trigger the callback + onGrabbedSlot.captured.invoke(token, amount, null) + + val event = awaitItem() + assertIs(event) + } +} +``` + +For suspend lambda callbacks that MockK can't capture directly (a known Kotlin 2.x +compatibility issue), use the `answers { args[N] as suspend ... }` pattern instead +of `capture(slot)`. + ## Guidance - **Inject, don't reach.** A ViewModel/coordinator that takes its dependencies diff --git a/libs/currency-math/build.gradle.kts b/libs/currency-math/build.gradle.kts index 179c960d9..ed90c7595 100644 --- a/libs/currency-math/build.gradle.kts +++ b/libs/currency-math/build.gradle.kts @@ -26,6 +26,7 @@ dependencies { val generateCurveTables by tasks.registering(GenerateCurveTables::class) { rustTableUrl.set("https://raw.githubusercontent.com/code-payments/flipcash-program/refs/heads/main/api/src/table.rs") outputDir.set(layout.projectDirectory.dir("src/main/assets")) + forceViaGradleProperty.set(providers.gradleProperty("forceCurveTables").map { true }.orElse(false)) // Always regenerate (useful for CI or development) // TODO: enable for CI when repo is public diff --git a/libs/emojis/build.gradle.kts b/libs/emojis/build.gradle.kts index e46624b47..e3aa087d9 100644 --- a/libs/emojis/build.gradle.kts +++ b/libs/emojis/build.gradle.kts @@ -1,11 +1,3 @@ -import java.net.URL -import java.nio.file.Files -import java.nio.file.StandardCopyOption -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive - plugins { alias(libs.plugins.flipcash.android.library) } @@ -21,14 +13,6 @@ dependencies { implementation(libs.androidx.datastore) } -private val emojiUrl = "https://unicode.org/Public/emoji/16.0/emoji-test.txt" -private val emojiKeywordsUrl = - "https://raw.githubusercontent.com/unicode-org/cldr-json/refs/heads/main/cldr-json/cldr-annotations-full/annotations/en/annotations.json" -val emojiFile = File(projectDir, "emoji-test.txt") // Local cache -val keywordsFile = File(projectDir, "en-keywords.json") // Local cache -private val outputDir = File(projectDir, "src/main/kotlin/com/getcode/libs/emojis/generated") -private val outputFile = File(outputDir, "Emojis.kt") - // Define the task to fetch and generate emoji data afterEvaluate { tasks.matching { it.name.matches(Regex("compile.*Kotlin")) }.configureEach { @@ -36,200 +20,9 @@ afterEvaluate { } } -tasks.register("generateEmojiList") { - description = - "Fetches Unicode emoji list and generates categorized Kotlin source file if needed" - group = "emoji" - - outputs.dir(outputDir) - // Skip generation if output already exists (cached emoji-test.txt and keywords are inputs) - onlyIf { !outputFile.exists() } - - doLast { - try { - outputDir.mkdirs() - if (!emojiFile.exists()) { - println("Downloading emoji-test.txt") - URL(emojiUrl).openStream().use { input -> - Files.copy(input, emojiFile.toPath(), StandardCopyOption.REPLACE_EXISTING) - } - } - - if (!keywordsFile.exists()) { - println("Downloading CLDR annotations") - URL(emojiKeywordsUrl).openStream().use { input -> - Files.copy(input, keywordsFile.toPath(), StandardCopyOption.REPLACE_EXISTING) - } - } - - val json = Json { ignoreUnknownKeys = true } - val cldrData = json.parseToJsonElement(keywordsFile.readText()).jsonObject - val cldrAnnotations = cldrData["annotations"]?.jsonObject?.get("annotations")?.jsonObject - - val emojiText = emojiFile.readText() - val emojiCategories = - mutableMapOf>>>() - val emojiCategoriesNoSkinTones = - mutableMapOf>>>() - var currentGroup = "Uncategorized" - var currentSubgroup = "Uncategorized" - - emojiText.lines().forEach { line -> - when { - line.startsWith("# group:") -> { - val groupName = line.removePrefix("# group:").trim() - currentGroup = when (groupName) { - "Smileys & Emotion", "People & Body" -> "Smileys & People" - else -> groupName - } - emojiCategories.getOrPut(currentGroup) { mutableMapOf() } - emojiCategoriesNoSkinTones.getOrPut(currentGroup) { mutableMapOf() } - } - - line.startsWith("# subgroup:") -> { - currentSubgroup = line.removePrefix("# subgroup:").trim() - emojiCategories[currentGroup]?.getOrPut(currentSubgroup) { mutableListOf() } - emojiCategoriesNoSkinTones[currentGroup]?.getOrPut(currentSubgroup) { mutableListOf() } - } - - line.isNotBlank() && !line.startsWith("#") -> { - val parts = line.split(";").map { it.trim() } - if (parts.size > 1 && parts[1].contains("fully-qualified")) { - val codePoints = parts[0].split(" ").map { it.toInt(16) } - val unicode = codePoints.map { codePoint -> - if (codePoint <= 0xFFFF) { - codePoint.toChar().toString() - } else { - String(Character.toChars(codePoint)) - } - }.joinToString("") - val nameParts = line.split("#")[1].trim().split(" ") - val name = nameParts.drop(2).joinToString(" ") - val baseKeywords = name.split(" ").filter { it.isNotBlank() } - - // Use CLDR data - val cldrAnnotation = cldrAnnotations?.get(unicode)?.jsonObject - val cldrKeywords = cldrAnnotation?.get("default")?.jsonArray?.mapNotNull { it.jsonPrimitive.toString().removeSurrounding("\"") }.orEmpty() - val allKeywords = (baseKeywords + cldrKeywords).distinct() - - val emojiEntry = mutableMapOf( - "unicode" to unicode, - "name" to name, - "keywords" to allKeywords - ) - emojiCategories[currentGroup]?.get(currentSubgroup)?.add(emojiEntry) - - val hasSkinTone = codePoints.any { it in 0x1F3FB..0x1F3FF } - if (!hasSkinTone) { - emojiCategoriesNoSkinTones[currentGroup]?.get(currentSubgroup) - ?.add(emojiEntry) - } - } - } - } - } - - // Filter out empty categories - val nonEmptyCategories = emojiCategories.filter { (_, subgroups) -> - subgroups.any { (_, emojis) -> emojis.isNotEmpty() } - } - val nonEmptyCategoriesNoSkinTones = - emojiCategoriesNoSkinTones.filter { (_, subgroups) -> - subgroups.any { (_, emojis) -> emojis.isNotEmpty() } - } - - // Generate a main Emojis.kt with category references and data class definition - val mainFile = File(outputDir, "Emojis.kt") - val mainCode = buildString { - appendLine("// Generated file - Do not edit manually") - appendLine("package com.getcode.libs.emojis.generated") - appendLine() - appendLine("data class Emoji(") - appendLine(" val unicode: String,") - appendLine(" val name: String,") - appendLine(" val keywords: List") - appendLine(")") - appendLine() - appendLine("enum class Category(val displayName: String) {") - nonEmptyCategories.keys.forEach { group -> - val enumName = group.replace("[^A-Za-z0-9]".toRegex(), "").uppercase() - appendLine(" $enumName(\"$group\"),") - } - appendLine(" FREQUENT(\"Frequently Used\"),") - appendLine("}") - appendLine() - appendLine("object Emojis {") - appendLine(" val categorized = mapOf(") - nonEmptyCategories.forEach { (group, subgroups) -> - val enumName = group.replace("[^A-Za-z0-9]".toRegex(), "").uppercase() - appendLine(" Category.$enumName to mapOf(") - subgroups.forEach { (subgroup, _) -> - val safeGroupName = group.replace("[^A-Za-z0-9]".toRegex(), "") - val safeSubgroupName = subgroup.replace("[^A-Za-z0-9]".toRegex(), "") - appendLine(" \"$subgroup\" to ${safeGroupName}${safeSubgroupName}Emojis.categorized,") - } - appendLine(" ),") - } - appendLine(" )") - appendLine() - appendLine(" val categorizedNoSkinTones = mapOf(") - nonEmptyCategoriesNoSkinTones.forEach { (group, subgroups) -> - val enumName = group.replace("[^A-Za-z0-9]".toRegex(), "").uppercase() - appendLine(" Category.$enumName to mapOf(") - subgroups.forEach { (subgroup, _) -> - val safeGroupName = group.replace("[^A-Za-z0-9]".toRegex(), "") - val safeSubgroupName = subgroup.replace("[^A-Za-z0-9]".toRegex(), "") - appendLine(" \"$subgroup\" to ${safeGroupName}${safeSubgroupName}Emojis.categorizedNoSkinTones,") - } - appendLine(" ),") - } - appendLine(" )") - appendLine("}") - }.trimIndent() - mainFile.writeText(mainCode) - - // Generate separate files for each subgroup - emojiCategories.forEach { (group, subgroups) -> - subgroups.forEach { (subgroup, emojis) -> - val safeGroupName = group.replace("[^A-Za-z0-9]".toRegex(), "") - val safeSubgroupName = subgroup.replace("[^A-Za-z0-9]".toRegex(), "") - val subgroupFile = - File(outputDir, "${safeGroupName}${safeSubgroupName}Emojis.kt") - val subgroupCode = buildString { - appendLine("// Generated file - Do not edit manually") - appendLine("package com.getcode.libs.emojis.generated") - appendLine() - appendLine("object ${safeGroupName}${safeSubgroupName}Emojis {") - appendLine(" val categorized = ${if (emojis.isEmpty()) "emptyList()" else "listOf("}") - if (emojis.isNotEmpty()) { - appendLine(" ${emojis.joinToString(",\n ") { "Emoji(\"${it["unicode"]}\", \"${it["name"]}\", listOf(${it["keywords"]?.let { k -> (k as List).joinToString { "\"$it\"" } }}))" }}") - appendLine(" )") - } - appendLine() - appendLine( - " val categorizedNoSkinTones = ${ - if (emojiCategoriesNoSkinTones[group]?.get( - subgroup - )?.isEmpty() != false - ) "emptyList()" else "listOf(" - }" - ) - val noSkinTones = - emojiCategoriesNoSkinTones[group]?.get(subgroup) ?: emptyList() - if (noSkinTones.isNotEmpty()) { - appendLine(" ${noSkinTones.joinToString(",\n ") { "Emoji(\"${it["unicode"]}\", \"${it["name"]}\", listOf(${it["keywords"]?.let { k -> (k as List).joinToString { "\"$it\"" } }}))" }}") - appendLine(" )") - } - appendLine("}") - }.trimIndent() - subgroupFile.writeText(subgroupCode) - } - } - val totalEmojis = emojiCategories.values.sumOf { it.values.sumOf { e -> e.size } } - println("Generated $totalEmojis emojis across ${emojiCategories.size} categories") - } catch (e: Exception) { - println("Error in generateEmojiList: ${e.message}") - throw e - } - } +tasks.register("generateEmojiList") { + emojiUrl.set("https://unicode.org/Public/emoji/16.0/emoji-test.txt") + emojiKeywordsUrl.set("https://raw.githubusercontent.com/unicode-org/cldr-json/refs/heads/main/cldr-json/cldr-annotations-full/annotations/en/annotations.json") + emojiCacheDir.set(layout.projectDirectory) + outputDir.set(layout.projectDirectory.dir("src/main/kotlin/com/getcode/libs/emojis/generated")) }