diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml
index 2bd62eb7a4..9bcbabad0c 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/core/src/androidMain/res/values/strings.xml b/apps/flipcash/core/src/androidMain/res/values/strings.xml
index 7e2f264f7c..73799d7a81 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
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 9740fa0757..0580f0b1bd 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 3ecf0b9cf2..83e0f889df 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 56dfdcba82..3b26049dcf 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 92296991ef..b84a27567b 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
}