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/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/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 19acacf47a..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,14 +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: 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 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()) + } +}