Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 3 additions & 20 deletions apps/flipcash/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@
<data android:pathPattern="/login.*" />
<data android:pathPattern="/token.*" />
<data android:pathPattern="/verify.*" />
<data android:pathPattern="/tab/.*" />
</intent-filter>

<!-- send.flipcash.com App Links -->
Expand All @@ -157,19 +158,13 @@
/>
</intent-filter>

<!-- Custom scheme (codewallet) -->
<!-- Custom scheme: flipcash://{route}. The route sits in the host slot, as on iOS. -->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data android:scheme="codewallet" />
<data android:host="send.flipcash.com" />
<data android:pathPattern="/c/.*" />

<data android:scheme="codewallet" />
<data android:host="app.flipcash.com" />
<data android:pathPattern="/token/.*" />
<data android:scheme="flipcash" />
</intent-filter>

<intent-filter android:autoVerify="true">
Expand Down Expand Up @@ -339,18 +334,6 @@
android:host="jump.flipcash.com"
android:scheme="https" />
</intent-filter>

<intent-filter>
<action android:name="android.intent.action.VIEW" />

<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />

<data
android:host="send.flipcash.com"
android:pathPattern="/c/.*"
android:scheme="codewallet" />
</intent-filter>
</activity>

<activity
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,11 @@ class DeeplinkScanPolicyTest {
assertFalse(DeeplinkType.EmailVerification(EMAIL, CODE).isScannable)
}

@Test
fun `tab links are not scannable`() {
assertFalse(DeeplinkType.Tab(NavBarButton.Wallet).isScannable)
}

private companion object {
const val ENTROPY = "0123456789abcdef"
const val USERNAME = "someone"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,9 @@ sealed interface DeeplinkType: Parcelable {
*/
@Serializable data class Profile(val address: ProfileAddress): DeeplinkType

/** A tab, from `app.flipcash.com/tab/{scan|chats|wallet|you}` or `flipcash://tab/{name}`. */
@Serializable data class Tab(val button: NavBarButton) : DeeplinkType

@Serializable
data class EmailVerification(
val email: String,
Expand Down Expand Up @@ -112,13 +115,16 @@ sealed interface DeeplinkType: Parcelable {

is Login,
is EmailVerification,
// A tab switch is navigation, not something a printed code is there to ask for.
is Tab,
-> false
}
}

val Uri.fragments: Map<Key, String>
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}=" }
Expand Down
1 change: 0 additions & 1 deletion apps/flipcash/core/src/androidMain/res/values/strings.xml
Original file line number Diff line number Diff line change
Expand Up @@ -1996,7 +1996,6 @@

<!-- Group send, node 11293:6918 -->
<string name="title_selectPeople">Select People</string>
<string name="title_splitBetween">Split Between</string>
<string name="title_groupSendTotal">Total Amount To Send</string>
<string name="header_groupSendMembers">Members</string>
<plurals name="action_sendToPeople">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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<String, NavBarButton> = 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
Expand Down Expand Up @@ -94,15 +109,15 @@ 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 {
// An invite-shaped link that no route claimed (`/chat/invite/extra#s=…`) is a broken
// 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) {
Expand Down Expand Up @@ -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()))
}
}

Expand Down Expand Up @@ -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()
}

/**
Expand All @@ -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
Expand Down Expand Up @@ -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)

/**
Expand Down Expand Up @@ -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))

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<DeeplinkType.GroupInvite>(https)
assertIs<DeeplinkType.GroupInvite>(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
}
Loading