From c8cf48244e24d744fa57e2e91d0a4ea96ed6e93d Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 23 Sep 2026 13:57:20 -0400 Subject: [PATCH] fix(scanner): open group chat invites scanned from a QR code Scanning an app.flipcash.com/chat/{uuid} QR code did nothing from the camera and showed "no code found" from the gallery. The router already classifies the link as a GroupChatInvite, but isScannable refused it and Scanner had no destination for it, so it fell through to an empty route list. GroupChatInvite is now scannable and opens the same destination as a tapped invite link: Sheets.Tips, then Messaging.Chat by chat id, which is the gated preview for someone who is not yet a member. Login and EmailVerification stay refused, since they carry secrets. --- .../com/flipcash/app/core/navigation/DeeplinkType.kt | 5 +---- .../app/core/navigation/DeeplinkScanPolicyTest.kt | 12 +----------- .../com/flipcash/app/scanner/internal/Scanner.kt | 7 +++++++ 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index 6f61db3baf..2a756c3514 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -60,9 +60,6 @@ sealed interface DeeplinkType: Parcelable { * send as a photo. A new route type is refused until someone adds it here, which is the * property an `else -> false` would not have. * - * [GroupChatInvite] is false because no scan path opens one -- `Scanner`'s `Navigatable` - * branch has no case for it. It is a gap named rather than a risk. - * * iOS's equivalent is `ScanViewModel.canScanQR`. */ val isScannable: Boolean @@ -72,11 +69,11 @@ sealed interface DeeplinkType: Parcelable { is TipChat, is Tipcard, is TipcardByUsername, + is GroupChatInvite, -> true is Login, is EmailVerification, - is GroupChatInvite, -> false } } diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt index f8ebfd13fb..d88b143a84 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/DeeplinkScanPolicyTest.kt @@ -23,6 +23,7 @@ class DeeplinkScanPolicyTest { assertTrue(DeeplinkType.TipChat(ChatIdentifier.ByChatId(CHAT_ID)).isScannable) assertTrue(DeeplinkType.Tipcard(USER_ID).isScannable) assertTrue(DeeplinkType.TipcardByUsername(USERNAME).isScannable) + assertTrue(DeeplinkType.GroupChatInvite(CHAT_ID).isScannable) } @Test @@ -31,17 +32,6 @@ class DeeplinkScanPolicyTest { assertFalse(DeeplinkType.EmailVerification(EMAIL, CODE).isScannable) } - /** - * Not a security judgement -- an invite is harmless. It is that no scan path has ever opened - * one: `Scanner`'s `Navigatable` branch has no case for it and falls to `emptyList()`. Naming - * it here keeps that a stated decision rather than a gap, and flipping it means adding the - * destination too. - */ - @Test - fun `a group chat invite is not a scan destination`() { - assertFalse(DeeplinkType.GroupChatInvite(CHAT_ID).isScannable) - } - private companion object { const val ENTROPY = "0123456789abcdef" const val USERNAME = "someone" 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 d48645eb35..e8c8641660 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 @@ -25,6 +25,7 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.AppRoute.Token.* +import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.extensions.navigateAll import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.app.core.tipping.TipCardOwner @@ -119,6 +120,12 @@ internal fun Scanner() { AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(deeplink.identifier), ) + // Same destination as the tapped /chat/{uuid} link: the chat screen is the + // gated preview when the viewer is not yet a member. + is DeeplinkType.GroupChatInvite -> listOf( + AppRoute.Sheets.Tips(), + AppRoute.Messaging.Chat(ChatIdentifier.ByChatId(deeplink.chatId)), + ) else -> emptyList() } if (routes.isNotEmpty()) {