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
41 changes: 27 additions & 14 deletions apps/flipcash/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -209,22 +209,25 @@
</intent-filter>

<!--
Vanity profile links: `flipcash.com/{username}` opens that user's tip card.
Tip card links on the bare host: `flipcash.com/{username}` for an account with a
claimed handle, `flipcash.com/{uuid}` for one without. Both open that user's tip
card.

Usernames only. The bare host also serves the website (/download, /privacy,
Those two shapes only. The bare host also serves the website (/download, /privacy,
/terms), so the path is held to the server's own `[a-z0-9_]{2,15}` handle charset
rather than claimed wholesale. That needs pathAdvancedPattern — pathPattern's glob
has no character sets — which the platform only understands from API 31; below
that the attribute is ignored and the filter matches the whole host, so AppRouter
makes the same distinction again in code (isVanityProfile) and leaves anything
that isn't a handle unrouted.

A-Z is in the set even though no handle contains one: the matcher is
case-sensitive, and a link is typed, printed, or auto-capitalised in any case.
Without it `flipcash.com/Sally_Streamer` isn't claimed at all on API 31+ and opens
in the browser, while the same link works below 31 — where the attribute is
ignored. Widening it hands the app a few more of the website's pages in mixed case
(`/Download`), which is what already happens below 31: isVanityProfile lowercases
and to a canonical 8-4-4-4-12 UUID rather than claimed wholesale. That needs
pathAdvancedPattern — pathPattern's glob has no character sets — which the
platform only understands from API 31; below that the attribute is ignored and the
filter matches the whole host, so AppRouter makes the same distinction again in
code (isProfileLink) and leaves anything that is neither shape unrouted.

A-Z is in the handle set even though no handle contains one, and A-F in the UUID
set even though Linkify writes lowercase: the matcher is case-sensitive, and a
link is typed, printed, or auto-capitalised in any case. Without it
`flipcash.com/Sally_Streamer` isn't claimed at all on API 31+ and opens in the
browser, while the same link works below 31 — where the attribute is ignored.
Widening it hands the app a few more of the website's pages in mixed case
(`/Download`), which is what already happens below 31: isProfileLink lowercases
before the reserved-path check, so those classify as unrouted and bounce straight
back out via DeeplinkAction.OpenExternally.

Expand All @@ -242,12 +245,22 @@
android:pathAdvancedPattern="/[a-zA-Z0-9_]{2,15}"
android:scheme="https"
tools:ignore="UnusedAttribute" />
<data
android:host="flipcash.com"
android:pathAdvancedPattern="/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
android:scheme="https"
tools:ignore="UnusedAttribute" />
<!-- AppRouter strips `www.` before matching, so the filter has to admit it. -->
<data
android:host="www.flipcash.com"
android:pathAdvancedPattern="/[a-zA-Z0-9_]{2,15}"
android:scheme="https"
tools:ignore="UnusedAttribute" />
<data
android:host="www.flipcash.com"
android:pathAdvancedPattern="/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{4}-[a-fA-F0-9]{12}"
android:scheme="https"
tools:ignore="UnusedAttribute" />
</intent-filter>

<intent-filter android:autoVerify="true">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import com.flipcash.services.models.isUsernameShaped
private const val ABBREVIATED_ID_LENGTH = 5

/**
* `https://app.flipcash.com/tip/<uuid>` -> `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/<uuid>` -> `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/<uuid>` 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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<String> =
val reservedProfilePaths: Set<String> =
// The website's own pages.
setOf(
"download", "privacy", "terms", "support", "help", "about", "blog", "legal",
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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`() {
Expand Down Expand Up @@ -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<DeeplinkType.Tipcard>(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<DeeplinkType.Tipcard>(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<DeeplinkType.Tipcard>(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<DeeplinkType.Tipcard>(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<DeeplinkAction.PresentTipCard>(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<DeeplinkAction.Navigate>(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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -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)
}
Expand Down
Loading
Loading