From 3dbdd5bfc607a94205e0314554cb8690ade94251 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 24 Aug 2026 11:59:04 -0400 Subject: [PATCH 1/2] fix(router): route your own tip card link to the You tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A `/tip/{self}` deeplink dead-ended: AppRouter always produced PresentTipCard, and the self-check lived downstream in TipCardDelegate as a bare `return` — so tapping your own tip card link did nothing at all. Decide it in the router instead. A self-targeted tip card link now resolves to Navigate(Sheets.Menu) — the You tab, which owns your tip card (NavBarRoutes: NavBarButton.TipCard -> Sheets.Menu). Every existing dispatch site already handles Navigate: cold start goes through buildNavGraphForLaunch, which replaces the launch home with a tab-home deeplink route, and the warm path goes through navigateAll, which wraps it as a sheet under v1 and swaps the tab under v2. The TipCardDelegate guard stays — scanning your own QR still reaches it — but its comment no longer claims the self case never comes through. Scanning your own tip code is unchanged and still no-ops. --- .../app/router/inject/RouterModule.kt | 5 ++- .../flipcash/app/router/internal/AppRouter.kt | 11 ++++- .../app/router/internal/AppRouterTest.kt | 45 ++++++++++++++++++- .../internal/delegates/TipCardDelegate.kt | 5 ++- 4 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt index 9b44eeb8c6..ee6dad79d6 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/inject/RouterModule.kt @@ -17,5 +17,8 @@ object RouterModule { @Provides fun providesRouter( userManager: UserManager, - ): Router = AppRouter(authStateProvider = { userManager.authState }) + ): Router = AppRouter( + authStateProvider = { userManager.authState }, + currentUserIdProvider = { userManager.accountId }, + ) } diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index 095e69b572..57fc2951e8 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -19,6 +19,7 @@ import com.flipcash.app.router.internal.AppRouter.Companion.tip import com.flipcash.app.router.internal.AppRouter.Companion.token import com.flipcash.app.router.internal.AppRouter.Companion.verification import com.flipcash.services.user.AuthState +import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.core.bytes import com.getcode.solana.keys.Mint import com.getcode.utils.TraceType @@ -31,6 +32,7 @@ import java.util.UUID internal class AppRouter( private val authStateProvider: () -> AuthState, + private val currentUserIdProvider: () -> ID?, ) : Router { companion object { val login = listOf("login") @@ -83,7 +85,14 @@ internal class AppRouter( listOf(AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(type.identifier)) ) - is DeeplinkType.Tipcard -> DeeplinkAction.PresentTipCard(type.userId) + // Your own tip card link: tipping yourself is a payment no-op, so instead of + // presenting a card that can't be acted on, land on the You tab — the surface that + // owns your tip card (see NavBarRoutes: NavBarButton.TipCard -> Sheets.Menu). + is DeeplinkType.Tipcard -> if (type.userId == currentUserIdProvider()) { + DeeplinkAction.Navigate(listOf(AppRoute.Sheets.Menu)) + } else { + DeeplinkAction.PresentTipCard(type.userId) + } } } diff --git a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt index 4c31ea26a7..c1657b9f3f 100644 --- a/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt +++ b/apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt @@ -8,12 +8,15 @@ import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.util.Linkify import com.flipcash.services.models.chat.ChatId import com.flipcash.services.user.AuthState +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.bytes import com.getcode.solana.keys.Mint import dev.theolm.rinku.DeepLink import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.annotation.Config import java.net.URLEncoder +import java.util.UUID import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertIs @@ -29,8 +32,12 @@ class AppRouterTest { } private var authState: AuthState = AuthState.Ready + private var currentUserId: ID? = null - private val router = AppRouter(authStateProvider = { authState }) + private val router = AppRouter( + authStateProvider = { authState }, + currentUserIdProvider = { currentUserId }, + ) private fun loggedIn() { authState = AuthState.Ready } private fun loggedOut() { authState = AuthState.LoggedOut } @@ -377,6 +384,42 @@ class AppRouterTest { assertEquals(sampleChatId, identifier.chatId) } + @Test + fun `dispatch presents the tip card for another user's tip card deeplink`() { + loggedIn() + currentUserId = UUID.fromString("22222222-2222-2222-2222-222222222222").bytes + + val userId = "11111111-1111-1111-1111-111111111111" + val action = router.dispatch(DeepLink("https://app.flipcash.com/tip/$userId")) + + assertIs(action) + assertEquals(UUID.fromString(userId).bytes, action.userId) + } + + @Test + fun `dispatch routes your own tip card deeplink to the You tab`() { + loggedIn() + val userId = "11111111-1111-1111-1111-111111111111" + currentUserId = UUID.fromString(userId).bytes + + val action = router.dispatch(DeepLink("https://app.flipcash.com/tip/$userId")) + + // Tipping yourself is a payment no-op, so the link lands on the You tab instead. + assertIs(action) + assertEquals(listOf(AppRoute.Sheets.Menu), action.routes) + } + + @Test + fun `dispatch presents the tip card when the current user id is unknown`() { + loggedIn() + currentUserId = null + + val userId = "11111111-1111-1111-1111-111111111111" + val action = router.dispatch(DeepLink("https://app.flipcash.com/tip/$userId")) + + assertIs(action) + } + // endregion // region dispatch — Logged in: EmailVerification (route building) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt index 19acacf47a..50eff5d93a 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt @@ -54,8 +54,9 @@ class TipCardDelegate @Inject constructor( private val inFlight = MutableStateFlow>(emptySet()) override fun resolveTipCard(user: ID) { - // You can't tip yourself: ignore a scanned or deeplinked own tip card. Your own card is - // shown by the You tab, which expands it in place — it never comes through this path. + // You can't tip yourself, so there's nothing to present for your own card. A `/tip/{self}` + // deeplink never reaches here — AppRouter turns it into a Navigate to the You tab, which + // owns your card. This guard covers the remaining entry point (a scan of your own code). // Mirrors iOS TipFlow.begin's `guard userID != session.userID`. if (user == tippingCoordinator.currentUserId) return if (!inFlight.add(user)) return From bfba0f873bef1186f59c945350459567b5fd9f67 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Mon, 24 Aug 2026 12:26:23 -0400 Subject: [PATCH 2/2] fix(scanner): route your own scanned tip card to the You tab MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Scanning your own tip card had the same dead end the deeplink did: the guard in TipCardDelegate.resolveTipCard returned bare, so the scan produced no visible outcome at all. Both scan shapes — a QR tip link (Scanner hands the id straight to resolveTipCard) and an OpenCode tip payload (CodeScanDelegate raises TipCardScanned, which the shell routes to the same call) — funnel through that one guard, so it's the place that has to answer for them. It now raises TipCardEvent.OwnCardScanned on a new UI-facing flow instead of returning silently, and the scanner collects it and navigates to Sheets.Menu — the same You tab the deeplink lands on. tipCardEvents is separate from TipCardDelegate.events: that channel is the shell's single consumer, this one is the UI's. It's replay-less, so an event raised with no scanner on screen is dropped, which is right — every producer runs while the scanner is composed. TipCardOperations is only implemented by TipCardDelegate, and RealSessionController delegates to it, so the new member propagates without touching the shell. --- .../flipcash/app/scanner/internal/Scanner.kt | 14 ++++ .../flipcash/app/session/SessionController.kt | 17 +++++ .../internal/delegates/TipCardDelegate.kt | 24 +++++- .../internal/delegates/TipCardDelegateTest.kt | 73 +++++++++++++++++++ 4 files changed, 124 insertions(+), 4 deletions(-) create mode 100644 apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index 09c374aced..a4230de256 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -24,6 +24,7 @@ import com.flipcash.app.featureflags.LocalFeatureFlags import com.flipcash.app.router.LocalRouter import com.flipcash.app.scanner.internal.bills.ScannableContainer import com.flipcash.app.session.LocalSessionController +import com.flipcash.app.session.TipCardEvent import com.getcode.libs.code.detection.CodeScanResult import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.ui.biometrics.LocalBiometricsState @@ -67,6 +68,19 @@ internal fun Scanner() { var isPinching by remember { mutableStateOf(false) } var zoomRatio by remember { mutableFloatStateOf(1f) } + // Scanning your own tip card resolves to nothing to pay, so send the user to the You tab — + // the surface that owns their card — rather than leaving the scan with no visible outcome. + // Covers both scan shapes (QR tip link and OpenCode tip payload); they share the guard in + // TipCardDelegate that raises this. The equivalent deeplink is handled in AppRouter. + LaunchedEffect(session, navigator, isNewUi) { + session.tipCardEvents.collect { event -> + when (event) { + TipCardEvent.OwnCardScanned -> + navigator.navigateAll(listOf(AppRoute.Sheets.Menu), isNewUi = isNewUi) + } + } + } + LaunchedEffect(biometricsState, previewing) { if (previewing == true) { focusManager.clearFocus() 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 0e07e3324e..0e1e83cbc0 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 @@ -9,6 +9,7 @@ import com.flipcash.app.core.AppRoute import com.getcode.opencode.model.core.ID import com.getcode.ui.core.RestrictionType import com.kik.kikx.models.ScannableKikCode +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow sealed interface BillDeterminationResult { @@ -34,7 +35,23 @@ interface CashLinkOperations { fun openCashLink(cashLink: String?) } +/** One-shot signals from tip card resolution that only the UI can act on. */ +sealed interface TipCardEvent { + /** + * The resolved card is the viewer's own. Tipping yourself is a payment no-op, so rather than + * present a card that can't be acted on, the UI sends them to the You tab — the surface that + * owns their tip card. Reached by scanning your own code (QR link or OpenCode payload); the + * `/tip/{self}` deeplink is diverted earlier, by the router. + */ + data object OwnCardScanned : TipCardEvent +} + interface TipCardOperations { + /** + * Hot and replay-less: an event emitted with no collector is dropped, which is correct here — + * every producer runs while the scanner is on screen. + */ + val tipCardEvents: Flow fun resolveTipCard(user: ID) } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt index 50eff5d93a..5abc9d6432 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt @@ -2,6 +2,7 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.bill.Scannable +import com.flipcash.app.session.TipCardEvent import com.flipcash.app.session.TipCardOperations import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.tipping.TippingCoordinator @@ -9,9 +10,12 @@ import com.getcode.opencode.model.core.ID import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.channels.BufferOverflow import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.flow.consumeAsFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch @@ -50,15 +54,27 @@ class TipCardDelegate @Inject constructor( private val _events = Channel(Channel.UNLIMITED) val events: Flow = _events.consumeAsFlow() + // Separate from [events]: that channel is the shell's (single-consumer, consumeAsFlow), while + // this one is the UI's. Replay-less, so an event with no scanner on screen is simply dropped. + private val _tipCardEvents = MutableSharedFlow( + extraBufferCapacity = 1, + onBufferOverflow = BufferOverflow.DROP_OLDEST, + ) + override val tipCardEvents: Flow = _tipCardEvents.asSharedFlow() + // Users with an in-flight resolve — coalesces duplicate requests (e.g. repeated scan frames). private val inFlight = MutableStateFlow>(emptySet()) override fun resolveTipCard(user: ID) { - // You can't tip yourself, so there's nothing to present for your own card. A `/tip/{self}` - // deeplink never reaches here — AppRouter turns it into a Navigate to the You tab, which - // owns your card. This guard covers the remaining entry point (a scan of your own code). + // You can't tip yourself, so there's no card to present for your own id. Both scan paths + // (a QR tip link and an OpenCode tip payload) land here, so this is the one place that has + // to answer for them: signal the UI to show the You tab, which owns your card, instead of + // silently doing nothing. A `/tip/{self}` deeplink is diverted earlier, by AppRouter. // Mirrors iOS TipFlow.begin's `guard userID != session.userID`. - if (user == tippingCoordinator.currentUserId) return + if (user == tippingCoordinator.currentUserId) { + _tipCardEvents.tryEmit(TipCardEvent.OwnCardScanned) + return + } if (!inFlight.add(user)) return scope.launch { diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt new file mode 100644 index 0000000000..59fc25587b --- /dev/null +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt @@ -0,0 +1,73 @@ +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.Scannable +import com.flipcash.app.session.TipCardEvent +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.shared.tipping.TippingCoordinator +import com.getcode.opencode.model.core.ID +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.async +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +/** + * Covers the self-tip guard: scanning your own tip card has nothing to pay, so instead of + * resolving a card it raises [TipCardEvent.OwnCardScanned] for the UI to act on. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class TipCardDelegateTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule() + + private val self: ID = List(16) { it.toByte() } + private val other: ID = List(16) { (it + 1).toByte() } + + private val tippingCoordinator = mockk(relaxed = true) { + every { currentUserId } returns self + } + private val analytics = mockk(relaxed = true) + + private fun delegate() = TipCardDelegate( + tippingCoordinator = tippingCoordinator, + analytics = analytics, + dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()), + ) + + @Test + fun `resolving your own tip card raises OwnCardScanned instead of resolving`() = runTest { + val delegate = delegate() + val event = async { delegate.tipCardEvents.first() } + // Let the collector attach before emitting — the flow is replay-less. + testScheduler.advanceUntilIdle() + + delegate.resolveTipCard(self) + + assertEquals(TipCardEvent.OwnCardScanned, event.await()) + coVerify(exactly = 0) { tippingCoordinator.resolveTipCard(any()) } + } + + @Test + fun `resolving another user's tip card resolves and presents it`() = runTest { + val card = mockk(relaxed = true) + coEvery { tippingCoordinator.resolveTipCard(other) } returns Result.success(card) + + val delegate = delegate() + val presented = async { delegate.events.first() } + testScheduler.advanceUntilIdle() + + delegate.resolveTipCard(other) + + assertEquals(TipCardDelegate.Event.Present(card), presented.await()) + } +}