From 7328192c7d50d32bcac1ed63236e1b4021b83247 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:26:35 -0400 Subject: [PATCH 1/2] fix(strings): drop the duplicate title_splitBetween that breaks resource conversion --- apps/flipcash/core/src/androidMain/res/values/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/flipcash/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml index 7e2f264f7..73799d7a8 100644 --- a/apps/flipcash/core/src/androidMain/res/values/strings.xml +++ b/apps/flipcash/core/src/androidMain/res/values/strings.xml @@ -1996,7 +1996,6 @@ Select People - Split Between Total Amount To Send Members From 995c3cd0de7d3143a46f421e25a12ccf6c4d58ef Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 30 Oct 2026 20:26:35 -0400 Subject: [PATCH 2/2] feat(deeplinks): open a tab from a link and use the flipcash:// scheme Adds app.flipcash.com/tab/{scan|chats|wallet|you} and flipcash://tab/{name}, matched case-insensitively; an unknown name or a bare /tab does nothing and `tab` is reserved so it never reads as a profile handle. flipcash://{route}/... is rewritten onto the app origin so every existing route classifies the same as its https link. codewallet:// is removed. Uri.fragments also splits on '#', so iOS-style login#e=... parses. --- .../flipcash/app/src/main/AndroidManifest.xml | 23 +--- .../core/navigation/DeeplinkScanPolicyTest.kt | 5 + .../app/core/navigation/DeeplinkType.kt | 8 +- .../flipcash/app/scanner/internal/Scanner.kt | 4 +- .../com/flipcash/app/analytics/Conversions.kt | 1 + .../flipcash/app/router/internal/AppRouter.kt | 51 ++++++++- .../app/router/internal/AppRouterTest.kt | 100 ++++++++++++++++++ 7 files changed, 165 insertions(+), 27 deletions(-) diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml index 2bd62eb7a..9bcbabad0 100644 --- a/apps/flipcash/app/src/main/AndroidManifest.xml +++ b/apps/flipcash/app/src/main/AndroidManifest.xml @@ -132,6 +132,7 @@ + @@ -157,19 +158,13 @@ /> - + - - - - - - - + @@ -339,18 +334,6 @@ android:host="jump.flipcash.com" android:scheme="https" /> - - - - - - - - - false } } val Uri.fragments: Map get() { - return this.toString().split("/") + // `#` is a boundary too: iOS writes `login#e=…` where Android's own links write `login/e=…`. + return this.toString().split("/", "#") .mapNotNull { fragment -> val data = Key.entries .map { key -> key to "${key.value}=" } 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 9740fa075..0580f0b1b 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 @@ -160,7 +160,9 @@ internal fun Scanner() { navigator.navigateAll(routes) } } - is DeeplinkType.Login -> Unit + // Not scannable (see isScannable); filtered before it gets here. + is DeeplinkType.Login, + is DeeplinkType.Tab -> Unit is DeeplinkType.Tipcard -> { session.resolveTipCard(TipCardOwner.ById(deeplink.userId)) } diff --git a/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt index 3ecf0b9cf..83e0f889d 100644 --- a/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt +++ b/apps/flipcash/shared/analytics/src/androidMain/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -145,6 +145,7 @@ val DeeplinkType.analytics: String is DeeplinkType.InvalidGroupInvite -> "InvalidGroupInvite" is DeeplinkType.Tipcard -> "Tipcard" is DeeplinkType.Profile -> "Profile" + is DeeplinkType.Tab -> "Tab" is DeeplinkType.EmailVerification -> "EmailVerification" } 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 56dfdcba8..3b26049dc 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 @@ -10,6 +10,8 @@ import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.isUsernameShaped import com.flipcash.app.core.navigation.Key +import com.flipcash.app.core.navigation.NavBarButton +import com.flipcash.app.core.navigation.destinationRoute import com.flipcash.app.core.navigation.fragments import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.verification.email.EmailDeeplinkOrigin @@ -19,6 +21,7 @@ import com.flipcash.app.router.Router import com.flipcash.app.router.internal.AppRouter.Companion.cashLink import com.flipcash.app.router.internal.AppRouter.Companion.chat import com.flipcash.app.router.internal.AppRouter.Companion.login +import com.flipcash.app.router.internal.AppRouter.Companion.tab 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 @@ -55,6 +58,18 @@ internal class AppRouter( val token = listOf("token") val chat = listOf("chat") val tip = listOf("tip") + val tab = listOf("tab") + + /** The `{name}` in `/tab/{name}`, matched case-insensitively. "You" is the Menu tab. */ + internal val tabNames: Map = mapOf( + "scan" to NavBarButton.Scanner, + "chats" to NavBarButton.Chats, + "wallet" to NavBarButton.Wallet, + "you" to NavBarButton.TipCard, + ) + + /** The custom scheme. The route sits in the host slot: `flipcash://tab/scan`. */ + const val CUSTOM_SCHEME = "flipcash" /** * Redirector host. It carries no route of its own — the real link is percent-encoded in @@ -94,7 +109,7 @@ internal class AppRouter( // Routes belonging to the app hosts. The apex answers for them too, so a link // naming one is a mis-hosted route, not somebody's handle. setOf("pool", "wallet") + - login + cashLink + verification + token + chat + tip + login + cashLink + verification + token + chat + tip + tab } override fun dispatch(deepLink: DeepLink): DeeplinkAction { @@ -102,7 +117,7 @@ internal class AppRouter( // invite, not a page for the browser: handing it on would send its fragment, the key, // to whatever opens it. val type = classify(deepLink) - ?: if (deepLink.mayCarryInviteSecret()) DeeplinkType.InvalidGroupInvite else return deepLink.unrouted() + ?: if (deepLink.mayCarryInviteSecret()) DeeplinkType.InvalidGroupInvite else return deepLink.withAppOrigin().unrouted() // Not logged in — redirect to login (or login deeplink itself) if (authStateProvider() !is AuthState.Ready) { @@ -171,6 +186,8 @@ internal class AppRouter( is DeeplinkType.Tipcard -> tipCard(TipCardOwner.ById(type.userId)) is DeeplinkType.Profile -> profile(type.address) + + is DeeplinkType.Tab -> DeeplinkAction.Navigate(listOf(type.button.destinationRoute())) } } @@ -227,12 +244,13 @@ internal class AppRouter( // A jump link may carry the real URL percent-encoded in `#source=`. Unwrap once and // classify that; a jump pointing at another jump is malformed and falls through rather // than recursing. Mirrors iOS DeepLinkController. - if (deepLink.isJump()) { - deepLink.unwrapJumpTarget() + val link = deepLink.withAppOrigin() + if (link.isJump()) { + link.unwrapJumpTarget() ?.takeUnless { it.isJump() } ?.let { return classifyOrThrow(it) } } - return deepLink.classifyByPath() + return link.classifyByPath() } /** @@ -253,6 +271,7 @@ internal class AppRouter( deepLink.isCashLink() -> deepLink.handleCashLink() deepLink.isToken() -> deepLink.handleTokenLink() deepLink.isEmailVerification() -> deepLink.handleEmailVerification() + deepLink.isTab() -> deepLink.handleTab() deepLink.isTipChat() -> deepLink.handleTipChat() deepLink.isTipCard() -> deepLink.handleTipCard() // Ahead of every other `/chat/` route: `/chat/invite` has a secret in its fragment and @@ -327,6 +346,18 @@ internal class AppRouter( } } +/** + * `flipcash://{route}/…` carries the route in the host slot. Rewrite it onto the app origin so + * every route below classifies it exactly as the https link it mirrors. Anything else is returned + * as is. + */ +private fun DeepLink.withAppOrigin(): DeepLink { + val scheme = data.substringBefore("://", missingDelimiterValue = "") + if (!scheme.equals(AppRouter.CUSTOM_SCHEME, ignoreCase = true)) return this + val rest = data.substringAfter("://") + return DeepLink("https://app.flipcash.com/$rest") +} + private fun DeepLink.isJump(): Boolean = host.equals(AppRouter.JUMP_HOST, ignoreCase = true) /** @@ -362,6 +393,16 @@ private fun DeepLink.isEmailVerification(): Boolean = verification.contains(path private fun DeepLink.isTipChat(): Boolean = tip.contains(pathSegments.getOrNull(0)) && chat.contains(pathSegments.getOrNull(1)) +// https://app.flipcash.com/tab/{scan|chats|wallet|you}: exactly two segments and a known name. +// Anything else is no link at all, not a profile — `tab` is in reservedProfilePaths. +private fun DeepLink.isTab(): Boolean = + tab.contains(pathSegments.getOrNull(0)?.lowercase()) && + pathSegments.size == 2 && + AppRouter.tabNames.containsKey(pathSegments[1].lowercase()) + +private fun DeepLink.handleTab(): DeeplinkType.Tab? = + AppRouter.tabNames[pathSegments.getOrNull(1)?.lowercase()]?.let { DeeplinkType.Tab(it) } + private fun DeepLink.isTipCard(): Boolean = tip.contains(pathSegments.getOrNull(0)) /** 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 92296991e..b84a27567 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 @@ -1380,4 +1380,104 @@ class AppRouterTest { } // endregion + + // region tab links and the flipcash:// scheme + + private fun tabAction(url: String) = router.dispatch(DeepLink(url)) + + @Test + fun `tab links open the matching tab over https and flipcash`() { + val expected = mapOf( + "scan" to AppRoute.Tabs.Scanner, + "chats" to AppRoute.Tabs.Chats, + "wallet" to AppRoute.Tabs.Wallet, + "you" to AppRoute.Tabs.Menu, + ) + expected.forEach { (name, route) -> + listOf("https://app.flipcash.com/tab/$name", "flipcash://tab/$name").forEach { url -> + assertEquals(DeeplinkAction.Navigate(listOf(route)), tabAction(url), url) + } + } + } + + @Test + fun `tab name is case insensitive`() { + assertEquals( + DeeplinkAction.Navigate(listOf(AppRoute.Tabs.Scanner)), + tabAction("https://app.flipcash.com/tab/SCAN"), + ) + assertEquals( + DeeplinkAction.Navigate(listOf(AppRoute.Tabs.Menu)), + tabAction("flipcash://tab/You"), + ) + } + + @Test + fun `unknown or missing tab name is no action and not a profile`() { + listOf( + "https://app.flipcash.com/tab/bogus", + "https://app.flipcash.com/tab", + "https://app.flipcash.com/tab/scan/extra", + "flipcash://tab/bogus", + "flipcash://tab", + "https://flipcash.com/tab", + ).forEach { url -> + assertNull(router.classify(DeepLink(url)), url) + } + assertEquals(DeeplinkAction.None, tabAction("https://app.flipcash.com/tab")) + assertEquals(DeeplinkAction.None, tabAction("https://app.flipcash.com/tab/bogus")) + assertTrue("tab" in AppRouter.reservedProfilePaths) + } + + @Test + fun `tab link while logged out goes to onboarding like other links`() { + loggedOut() + assertEquals( + DeeplinkAction.Navigate(listOf(AppRoute.OnboardingFlow())), + tabAction("flipcash://tab/wallet"), + ) + } + + @Test + fun `flipcash scheme classifies like the equivalent https link`() { + val chatId = Linkify.tipChatById(sampleChatId).substringAfterLast('/') + listOf( + "login#e=abc123" to "login#e=abc123", + "login?data=xyz789" to "login?data=xyz789", + "token/$MINT" to "token/$MINT", + "c#e=abc123" to "c#e=abc123", + "c/#/e=abc123" to "c/#/e=abc123", + "cash#e=abc123" to "cash#e=abc123", + "tip/chat/$chatId" to "tip/chat/$chatId", + "chat/${groupUuid}" to "chat/${groupUuid}", + "chat/$chatId/m/12" to "chat/$chatId/m/12", + "tip/${groupUuid}" to "tip/${groupUuid}", + "verify?email=a%40b.co&code=123456" to "verify?email=a%40b.co&code=123456", + ).forEach { (custom, web) -> + val expected = router.classify(DeepLink("https://app.flipcash.com/$web")) + assertTrue(expected != null, web) + assertEquals(expected, router.classify(DeepLink("flipcash://$custom")), custom) + } + } + + @Test + fun `flipcash scheme group invite matches the https invite`() { + val secret = ByteArray(32) { (it + 1).toByte() } + val web = InviteLink.url(secret) + val fragment = web.substringAfter('#') + val https = router.classify(DeepLink("https://app.flipcash.com/chat/invite#$fragment")) + val custom = router.classify(DeepLink("flipcash://chat/invite#$fragment")) + assertIs(https) + assertIs(custom) + assertTrue(https.secret.contentEquals(custom.secret)) + } + + @Test + fun `codewallet scheme is not handled`() { + val url = "codewallet://send.flipcash.com/c/abc" + assertNull(router.classify(DeepLink(url))) + assertEquals(DeeplinkAction.None, router.dispatch(DeepLink(url))) + } + + // endregion }