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"))
}