From 3d7e82410654234ac09fb4fb5f6b768b8458e58e Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 27 Aug 2026 11:28:58 -0400 Subject: [PATCH] feat(tips): put the tipcard link at the root when there's no handle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An unclaimed card shared `app.flipcash.com/tip/{uuid}` while a claimed one shared `flipcash.com/{handle}`, so claiming a handle changed the host and the path shape of a link its owner may already have printed or posted. Both forms now sit at the root of the apex host: `flipcash.com/{uuid}` until there's a handle, `flipcash.com/{handle}` after. AppRouter's bare-host branch takes either shape. A uuid can't be mistaken for a handle — dashes aren't in the `[a-z0-9_]{2,15}` charset and 36 characters overruns it — so the two share the root without ambiguity, and only the handle form needs the reserved-path list that keeps `/download` and `/privacy` with the website. The shape test is a regex rather than `UUID.fromString`, which also accepts `1-1-1-1-1`: an unrecognised path on this host goes back to the browser, and claiming a looser shape would take taps away from pages the website serves. The manifest gains a matching uuid `pathAdvancedPattern`. As with the handle pattern, the platform ignores it below API 31 and the whole host arrives, which is why `isProfileLink` makes the same distinction in code. `app.flipcash.com/tip/{uuid}` still routes. Links already shared carry it; nothing writes it any more. Matches code-payments/code-ios-app#670, which moves the same link to the root. --- .../flipcash/app/src/main/AndroidManifest.xml | 41 ++++++++---- .../app/core/navigation/DeeplinkAction.kt | 7 +- .../app/core/navigation/DeeplinkType.kt | 6 +- .../com/flipcash/app/core/util/Linkify.kt | 17 +++-- .../app/menu/internal/AbbreviatedLink.kt | 7 +- .../app/menu/internal/AbbreviatedLinkTest.kt | 8 +++ .../flipcash/app/scanner/internal/Scanner.kt | 3 +- .../flipcash/app/router/internal/AppRouter.kt | 63 ++++++++++++------ .../app/router/internal/AppRouterTest.kt | 65 ++++++++++++++++++- .../flipcash/app/session/SessionController.kt | 6 +- .../internal/delegates/TipCardDelegate.kt | 2 +- maestro/README.md | 2 +- 12 files changed, 171 insertions(+), 56 deletions(-) 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