diff --git a/apps/flipcash/app/src/main/AndroidManifest.xml b/apps/flipcash/app/src/main/AndroidManifest.xml
index 7cf5ecee1..abee5b5d4 100644
--- a/apps/flipcash/app/src/main/AndroidManifest.xml
+++ b/apps/flipcash/app/src/main/AndroidManifest.xml
@@ -209,22 +209,25 @@
+
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt
index 826de2160..8da964151 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkAction.kt
@@ -9,9 +9,10 @@ sealed interface DeeplinkAction {
data class OpenCashLink(val entropy: String) : DeeplinkAction
/**
- * Present someone's tip card. [owner] carries how the link named them — a `/tip/{id}` link by
- * id, a vanity `flipcash.com/{username}` link by handle — because resolving the handle is a
- * server round trip, and that belongs to the session rather than to the router.
+ * Present someone's tip card. [owner] carries how the link named them — `flipcash.com/{id}`
+ * (or the older `/tip/{id}`) by id, `flipcash.com/{username}` by handle — because resolving
+ * the handle is a server round trip, and that belongs to the session rather than to the
+ * router.
*/
data class PresentTipCard(val owner: TipCardOwner): DeeplinkAction
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 7fed5caf6..e8662f73a 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
@@ -20,10 +20,14 @@ sealed interface DeeplinkType: Parcelable {
@Serializable data class TipChat(val identifier: ChatIdentifier): DeeplinkType, Navigatable
+ /**
+ * A tip card addressed by account id — `flipcash.com/{uuid}`, or the older
+ * `app.flipcash.com/tip/{uuid}` that links already shared still carry.
+ */
@Serializable data class Tipcard(val userId: ID): DeeplinkType
/**
- * A vanity `flipcash.com/{username}` link — the same destination as [Tipcard], addressed by the
+ * A `flipcash.com/{username}` link — the same destination as [Tipcard], addressed by the
* owner's public handle. The id it resolves to is the server's to supply, so it stays a
* username all the way to the session.
*/
diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt
index 56f64012a..2e4822e64 100644
--- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt
+++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt
@@ -16,16 +16,19 @@ object Linkify {
/**
* A tip card's URL, in the form its [owner] is named by.
*
- * The handle form — `flipcash.com/sally_streamer` (node 9442:3673) — is what an account shows
- * and shares once it has claimed a username, because it reads as a person rather than as a
- * UUID; the id form stays the address for an account without one. [TipCardOwner.preferringUsername]
- * is that precedence, for callers that hold both.
+ * One segment on the bare host either way — `flipcash.com/sally_streamer` (node 9442:3673)
+ * for an account that has claimed a handle, `flipcash.com/{uuid}` for one that hasn't. The
+ * handle is what an account shows and shares once it has one, because it reads as a person
+ * rather than as a UUID; [TipCardOwner.preferringUsername] is that precedence, for callers
+ * that hold both.
*
- * Note the handle form's bare host, no `app.` subdomain: the manifest claims `flipcash.com` for
- * username-shaped paths only, so this is the exact shape that has to resolve back into the app.
+ * Note the bare host, no `app.` subdomain: the manifest claims `flipcash.com` for
+ * handle-shaped and UUID-shaped paths only, so these are the exact shapes that have to
+ * resolve back into the app. The older `app.flipcash.com/tip/{uuid}` form is still routed —
+ * links already shared carry it — but nothing writes it any more.
*/
fun tipcard(owner: TipCardOwner): String = when (owner) {
- is TipCardOwner.ById -> "https://app.flipcash.com/tip/${owner.userId.uuid}"
+ is TipCardOwner.ById -> "https://flipcash.com/${owner.userId.uuid}"
is TipCardOwner.ByUsername -> "https://flipcash.com/${owner.username}"
}
diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt
index 851709b4c..c43c6917e 100644
--- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt
+++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/AbbreviatedLink.kt
@@ -6,9 +6,10 @@ import com.flipcash.services.models.isUsernameShaped
private const val ABBREVIATED_ID_LENGTH = 5
/**
- * `https://app.flipcash.com/tip/` -> `app.flipcash.com/tip/b0ced…` (node 9276:4753). The
- * user never types this — it's a recognisable stand-in for the link the copy button puts on the
- * clipboard, so it's cut short rather than ellipsized at whatever width the device happens to give.
+ * `https://flipcash.com/` -> `flipcash.com/b0ced…` (node 9276:4753). The user never types
+ * this — it's a recognisable stand-in for the link the copy button puts on the clipboard, so it's
+ * cut short rather than ellipsized at whatever width the device happens to give. The older
+ * `app.flipcash.com/tip/` form abbreviates the same way, since only the last segment is cut.
*
* A vanity link is left whole: `flipcash.com/sally_streamer` (node 9442:3673) is the entire point
* of claiming a handle, it fits, and abbreviating it would hide the part that identifies the
diff --git a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt
index 0944ec6ae..2cfc64e0e 100644
--- a/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt
+++ b/apps/flipcash/features/menu/src/test/kotlin/com/flipcash/app/menu/internal/AbbreviatedLinkTest.kt
@@ -13,6 +13,14 @@ class AbbreviatedLinkTest {
)
}
+ @Test
+ fun `abbreviates an id link on the bare host`() {
+ assertEquals(
+ "flipcash.com/b0ced…",
+ "https://flipcash.com/b0ced1f2-a3b4-c5d6-e7f8-091a2b3c4d5e".abbreviatedLink(),
+ )
+ }
+
@Test
fun `a vanity link is left whole`() {
assertEquals(
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 afd6bf254..75c3ee410 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
@@ -128,7 +128,8 @@ internal fun Scanner() {
session.resolveTipCard(TipCardOwner.ById(deeplink.userId))
}
// A printed or on-screen `flipcash.com/{username}` is the
- // same card as a scanned /tip/{id}, addressed by handle.
+ // same card as a scanned `flipcash.com/{id}`, addressed by
+ // handle.
is DeeplinkType.TipcardByUsername -> {
session.resolveTipCard(TipCardOwner.ByUsername(deeplink.username))
}
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 bae5b7fe7..63dd5caf3 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
@@ -59,14 +59,14 @@ internal class AppRouter(
const val JUMP_HOST = "jump.flipcash.com"
/**
- * The bare host, which serves the website *and* every user's vanity profile link
- * (`flipcash.com/sally_streamer`). Distinct from the `app.` / `send.` hosts, whose whole
- * path space belongs to the app.
+ * The bare host, which serves the website *and* every user's tip card link — by handle
+ * (`flipcash.com/sally_streamer`) or by account id (`flipcash.com/{uuid}`). Distinct from
+ * the `app.` / `send.` hosts, whose whole path space belongs to the app.
*/
- const val VANITY_HOST = "flipcash.com"
+ const val PROFILE_HOST = "flipcash.com"
/**
- * Paths on [VANITY_HOST] that belong to the website rather than to a person. Every one of
+ * Paths on [PROFILE_HOST] that belong to the website rather than to a person. Every one of
* them is charset-valid as a username, and the server reserves them — so a link to one
* could only ever fail to resolve, but it would fail *inside* the app, having taken the tap
* away from the browser. Ruling them out here keeps `flipcash.com/download` a web link.
@@ -79,7 +79,7 @@ internal class AppRouter(
* Only its handle-shaped entries appear here — it also excludes paths the filter could
* never match (`/favicon.ico`, `/robots.txt`, anything multi-segment).
*/
- val reservedVanityPaths: Set =
+ val reservedProfilePaths: Set =
// The website's own pages.
setOf(
"download", "privacy", "terms", "support", "help", "about", "blog", "legal",
@@ -184,7 +184,7 @@ internal class AppRouter(
deepLink.isEmailVerification() -> deepLink.handleEmailVerification()
deepLink.isTipChat() -> deepLink.handleTipChat()
deepLink.isTipCard() -> deepLink.handleTipCard()
- deepLink.isVanityProfile() -> deepLink.handleVanityProfile()
+ deepLink.isProfileLink() -> deepLink.handleProfileLink()
// `/chat/{id}` links are intentionally NOT handled: the Send tab / direct-send
// flow they opened was removed. The manifest no longer claims that path either, so
// such a link opens in the browser rather than dead-ending here. Re-add routing and
@@ -204,7 +204,7 @@ internal class AppRouter(
* below API 31). Send those back to a browser instead of dead-ending on the home screen.
*/
private fun DeepLink.unrouted(): DeeplinkAction =
- if (host.removePrefix("www.").equals(VANITY_HOST, ignoreCase = true)) {
+ if (host.removePrefix("www.").equals(PROFILE_HOST, ignoreCase = true)) {
DeeplinkAction.OpenExternally(data)
} else {
DeeplinkAction.None
@@ -290,26 +290,47 @@ private fun DeepLink.isTipChat(): Boolean =
private fun DeepLink.isTipCard(): Boolean = tip.contains(pathSegments.getOrNull(0))
/**
- * `flipcash.com/{username}` — a single path segment on the bare host, shaped like a handle and not
- * one of the website's own pages.
+ * A tip card link on the bare host: one path segment naming its owner, either as a handle
+ * (`flipcash.com/sally_streamer`) or as an account id (`flipcash.com/{uuid}`).
*
- * All three conditions matter. The manifest claims this host for username-shaped paths only, but a
- * `pathAdvancedPattern` is ignored below API 31, so on those versions the whole host arrives here
- * and this is the only place the distinction is made.
+ * A handle has to clear two more conditions than the id does — the server's charset, and not being
+ * one of the website's own pages — because it shares a shape with `/download` and `/privacy`. A
+ * UUID can't collide with either, so its shape is the whole test.
+ *
+ * The manifest claims this host for those two path shapes only, but a `pathAdvancedPattern` is
+ * ignored below API 31, so on those versions the whole host arrives here and this is the only
+ * place the distinction is made.
*/
-private fun DeepLink.isVanityProfile(): Boolean {
- if (!host.removePrefix("www.").equals(AppRouter.VANITY_HOST, ignoreCase = true)) return false
+private fun DeepLink.isProfileLink(): Boolean {
+ if (!host.removePrefix("www.").equals(AppRouter.PROFILE_HOST, ignoreCase = true)) return false
val segment = pathSegments.singleOrNull()?.lowercase() ?: return false
- return segment.isUsernameShaped() && segment !in AppRouter.reservedVanityPaths
+ return segment.isUuidShaped() ||
+ (segment.isUsernameShaped() && segment !in AppRouter.reservedProfilePaths)
}
-private fun DeepLink.handleVanityProfile(): DeeplinkType.TipcardByUsername? {
- // Lowercased, not just matched case-insensitively: handles are lowercase on the wire, and this
- // string is what the profile lookup is keyed by.
- val username = pathSegments.singleOrNull()?.lowercase() ?: return null
- return DeeplinkType.TipcardByUsername(username)
+private fun DeepLink.handleProfileLink(): DeeplinkType? {
+ // Lowercased, not just matched case-insensitively: handles are lowercase on the wire and a
+ // UUID is written lowercase, and this string is what the lookup is keyed by.
+ val segment = pathSegments.singleOrNull()?.lowercase() ?: return null
+ return if (segment.isUuidShaped()) {
+ DeeplinkType.Tipcard(UUID.fromString(segment).bytes)
+ } else {
+ DeeplinkType.TipcardByUsername(segment)
+ }
}
+/**
+ * Canonical 8-4-4-4-12 lowercase hex — the exact shape `Linkify.tipcard` writes and the manifest's
+ * `pathAdvancedPattern` claims.
+ *
+ * Not `UUID.fromString`, which also accepts short groups (`1-1-1-1-1`): a link the app doesn't
+ * recognise on this host goes back to the browser, and claiming a shape the website might serve
+ * would take that tap away from it.
+ */
+private fun String.isUuidShaped(): Boolean = UuidPattern.matches(this)
+
+private val UuidPattern = Regex("^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$")
+
private fun DeepLink.handleLoginLink(): DeeplinkType.Login? {
val uri = data.toUri()
var entropy = uri.fragments[Key.entropy]
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 6d4858bc8..6cce8b0bc 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
@@ -424,7 +424,7 @@ class AppRouterTest {
// endregion
- // region classify + dispatch — vanity profile links
+ // region classify + dispatch — tip card links on the bare host
@Test
fun `classify recognizes a vanity profile link`() {
@@ -480,6 +480,69 @@ class AppRouterTest {
assertNull(router.classify(DeepLink("https://flipcash.com/CurrencyCreator")))
}
+ @Test
+ fun `classify recognizes a tip card link addressed by account id`() {
+ val userId = "11111111-2222-3333-4444-555555555555"
+ val type = router.classify(DeepLink("https://flipcash.com/$userId"))
+ assertIs(type)
+ assertEquals(UUID.fromString(userId).bytes, type.userId)
+ }
+
+ @Test
+ fun `classify recognizes an id link written by Linkify`() {
+ val userId = UUID.fromString("11111111-2222-3333-4444-555555555555").bytes
+ val type = router.classify(DeepLink(Linkify.tipcard(TipCardOwner.ById(userId))))
+ assertIs(type)
+ assertEquals(userId, type.userId)
+ }
+
+ // A link is typed, printed, or auto-capitalised in any case; the id it resolves to is the same.
+ @Test
+ fun `classify recognizes an id link in mixed case`() {
+ val type = router.classify(DeepLink("https://flipcash.com/AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE"))
+ assertIs(type)
+ assertEquals(UUID.fromString("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee").bytes, type.userId)
+ }
+
+ @Test
+ fun `classify recognizes an id link on the www host`() {
+ val type = router.classify(DeepLink("https://www.flipcash.com/11111111-2222-3333-4444-555555555555"))
+ assertIs(type)
+ }
+
+ @Test
+ fun `dispatch presents the tip card for another user's id link`() {
+ loggedIn()
+ currentUserId = UUID.fromString("22222222-2222-2222-2222-222222222222").bytes
+
+ val userId = "11111111-1111-1111-1111-111111111111"
+ val action = router.dispatch(DeepLink("https://flipcash.com/$userId"))
+
+ assertIs(action)
+ assertEquals(TipCardOwner.ById(UUID.fromString(userId).bytes), action.owner)
+ }
+
+ @Test
+ fun `dispatch routes your own id link to the You tab`() {
+ loggedIn()
+ val userId = "11111111-1111-1111-1111-111111111111"
+ currentUserId = UUID.fromString(userId).bytes
+
+ val action = router.dispatch(DeepLink("https://flipcash.com/$userId"))
+
+ assertIs(action)
+ assertEquals(AppRoute.Sheets.Menu, action.routes.single())
+ }
+
+ // UUID.fromString would take these; the regex doesn't. A shape the app doesn't claim goes back
+ // to the browser rather than being captured from the website.
+ @Test
+ fun `classify ignores a path that only loosely resembles a UUID`() {
+ assertNull(router.classify(DeepLink("https://flipcash.com/1-1-1-1-1")))
+ assertNull(router.classify(DeepLink("https://flipcash.com/11111111-2222-3333-4444-5555555555")))
+ assertNull(router.classify(DeepLink("https://flipcash.com/gggggggg-2222-3333-4444-555555555555")))
+ }
+
@Test
fun `classify ignores a vanity path that isn't shaped like a handle`() {
// Too short, too long, and outside the server's charset.
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 9438a2768..7d0b80c9f 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
@@ -42,7 +42,7 @@ 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.
+ * `flipcash.com/{self}` deeplink is diverted earlier, by the router.
*/
data object OwnCardScanned : TipCardEvent
}
@@ -56,8 +56,8 @@ interface TipCardOperations {
/**
* Resolves [owner]'s tip card and presents it. Both ways of naming them arrive here — a scan or
- * a `/tip/{id}` link by id, a `flipcash.com/{username}` link by handle — because everything
- * after resolution is the same card.
+ * a `flipcash.com/{id}` link by id, a `flipcash.com/{username}` link by handle — because
+ * everything after resolution is the same card.
*/
fun resolveTipCard(owner: TipCardOwner)
}
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 50e09dfa4..7d9daec32 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
@@ -30,7 +30,7 @@ import javax.inject.Singleton
/**
* Implements [TipCardOperations] — the single public entry point for presenting another
- * user's tip card, whether it arrives via a deeplink (`/tip/{userId}` or the vanity
+ * user's tip card, whether it arrives via a deeplink (`flipcash.com/{userId}` or the vanity
* `flipcash.com/{username}`), a scanned QR link, or a scanned OpenCode tip payload
* (see [CodeScanDelegate.onTipCardScanned]).
*
diff --git a/maestro/README.md b/maestro/README.md
index a27451c63..205cb1815 100644
--- a/maestro/README.md
+++ b/maestro/README.md
@@ -19,7 +19,7 @@ iOS's `FlipcashUITests`. Flows are plain YAML under `maestro/`; reusable pieces
```
SEED_PHRASE=word1 word2 ... word12 # primary account (tip-enabled)
LOGIN_DEEPLINK=https://app.flipcash.com/login?data=... # same account as SEED_PHRASE
- TIPCARD_DEEPLINK=https://app.flipcash.com/tip/... # the primary account's tip card
+ TIPCARD_DEEPLINK=https://flipcash.com/ # the primary account's tip card
LOGIN_USERNAME=sally_streamer # the handle that same account has claimed
USDF_ONLY_DEEPLINK=https://app.flipcash.com/login?data=... # reserves-only gate account
CONTACT_NAME=Brandon McAnsh # an on-Flipcash contact for send-to-contact