Skip to content

feat(username): claim a handle, and reach a tip card by it - #1330

Merged
bmc08gt merged 4 commits into
code/cashfrom
feat/username-ui
Aug 26, 2026
Merged

feat(username): claim a handle, and reach a tip card by it#1330
bmc08gt merged 4 commits into
code/cashfrom
feat/username-ui

Conversation

@bmc08gt

@bmc08gt bmc08gt commented Aug 26, 2026

Copy link
Copy Markdown
Collaborator

Usernames get an entry screen, a balance-gated nudge on the You tab, and a place in every surface that until now showed only an opaque user id. Figma nodes 9491:6295 through 9491:6299.

Claiming a handle

UpdateProfileStep.Username joins the update-profile stack as a conditional step alongside display name, so the same screen serves first-time setup and a later change. AppRoute.UpdateUserProfile gains includeUsername, threaded into buildUpdateUserProfileStack(includeName, includeUsername, includePhoto). Two entry points: the You tab's progress card (node 9491:6295) and a new Change Username row in My Account (node 9491:6297).

The server enforces a minimum balance on submit (SetUsernameError.InsufficientBalance). usernameGate(username, minimum, balance) reads the same usernameMinBalance flag locally so the card can show the distance rather than let someone type a handle into a rejection. It fails open on a zero or unresolved minimum, and clamps its progress fraction to 0f..1f — a negative balance previously drew the bar backwards.

Every rejection surfaces through showInfo, not showError: taken, too short, too long, reserved, moderated all describe what was typed and are fixed by typing something else, so the destructive style would read as a fault in the app. Only a failed check stays an error. Length is checked locally so "Too Short" and "Too Long" name the actual problem instead of arriving as a generic INVALID_USERNAME.

Addressing a tip card by handle

TipCardOwner is a sealed type — ById(userId) or ByUsername(username) — and replaces the bare ID at three seams: DeeplinkAction.PresentTipCard, SessionController.resolveTipCard, and Linkify.tipcard. Resolving a handle is a server round trip, so it stays a handle from the router all the way to the session, which owns the fetch.

The link row renders flipcash.com/sally_streamer whole (node 9442:3673) and abbreviates only an opaque id to app.flipcash.com/tip/b0ced… (node 9276:4753) — the handle is the point of claiming one, and cutting it would hide the part that identifies the person. The test is the handle's own shape, so nothing else is ever truncated.

flipcash.com/{username} deeplinks

A new autoVerify intent-filter claims flipcash.com and www.flipcash.com, narrowed to the handle charset with pathAdvancedPattern="/[a-z0-9_]{2,15}".

Android verifies App Links per host, not per pathassetlinks.json has no path scoping — and pathAdvancedPattern is API 31+ with no negative lookahead. So the website's own single-segment paths (/download, /privacy, /terms) satisfy the filter too. AppRouter keeps the reserved list and answers those with DeeplinkAction.OpenExternally, which hands the URL to a named browser rather than dead-ending the tap on the home screen. It has to be named: a package-less ACTION_VIEW on a host we are a verified handler for resolves straight back to us and loops.

This differs from iOS deliberately — their AASA supports path exclusions, so the same list lives in their apple-app-site-association file rather than in code. iOS also makes no www.flipcash.com claim; if that stays true the two platforms will disagree about www links.

Following your own handle

Two self-checks already existed, in AppRouter and in TipCardOwner.isSelf, and both compare handles — which they cannot do until this account's own profile has loaded. That is exactly the window a cold-started link lands in, so a link to your own handle got past both.

TippingCoordinator.resolveTipCard now compares the id off the wire after the fetch and raises OwnTipCard, which TipCardDelegate answers with TipCardEvent.OwnCardScanned — the same answer as the pre-dispatch guard. Raised as a failure rather than returning a card so the resolve stops short of its side effects (arming the tip modal, vibrating) for a card nobody will tip. Mirrors iOS TipFlow.prepare's second guard.

Elsewhere

  • ChatParticipant.handle exposes the handle to chat rows; Handle.kt centralises the @ prefix, the length bounds, and the shape regex that all three consumers depend on.
  • Analytics.AddMoneySource.UsernameShortfall reports a deposit prompted by the gate, so it is not counted as a deliberate visit to Add Money. The wire string matches iOS.

Depends on

assetlinks.json must be served from https://flipcash.com/.well-known/ before Android will verify the new host. Until it is, the links open in a browser instead of the app.

Usernames get an entry screen, a balance-gated nudge on the You tab, and a
place in every surface that until now showed only an opaque user id.

- `Username` joins the update-profile stack as a conditional step alongside
  display name (node 9491:6296), reachable from the You tab's progress card
  (node 9491:6295) and a new My Account row (node 9491:6297).
- `usernameGate` reads the `usernameMinBalance` flag locally, so the card shows
  how far off the balance is instead of letting someone type a handle into the
  server's rejection. Rejections show as info rather than error: each describes
  what was typed and is fixed by typing something else. Length is checked here
  so "too short" and "too long" name the problem rather than arriving as a
  generic INVALID_USERNAME.
- `TipCardOwner` replaces the bare `ID` at the deeplink, session, and Linkify
  seams, so a card can be addressed by id or by handle. A vanity link renders
  whole; only an opaque id is abbreviated (node 9442:3673).
- `flipcash.com/{username}` is claimed as a verified App Link, narrowed to the
  handle charset by `pathAdvancedPattern`. Android verifies per host and not
  per path, so the website's own paths (/download, /privacy, /terms) are listed
  in `AppRouter` and handed back to a browser through
  `DeeplinkAction.OpenExternally` rather than dead-ending on the home screen.
- Following your own handle answers with the You tab. Both handle-based
  self-checks are blind until this account's profile has loaded, which is
  exactly the window a cold-started link lands in, so `TippingCoordinator`
  compares ids after the fetch and raises `OwnTipCard` before the resolve arms
  the tip modal.

The new host needs `assetlinks.json` served from
`https://flipcash.com/.well-known/` before Android will verify it.
@github-actions github-actions Bot added type: feature New functionality area: payments Payments, transfers, intents, billing area: network gRPC, connectivity, API, exchange rates area: scanner QR/Kikcode scanning, camera area: notifications Push notifications, in-app messaging area: tokens Token accounts, balances, token info area: deeplinks Deep link handling, URL routing, and link parsing area: session and removed type: feature New functionality labels Aug 26, 2026
The handle comes from `LOGIN_USERNAME` rather than being written into the
flows, so the suite doesn't carry a real account's handle and a rotated test
account is a one-line `.env` change. `run.sh` forwards it alongside the other
credentials.

- `vanity_deeplink_self.yaml` — the account that owns the handle follows its own
  `flipcash.com/{handle}` link and lands on the You tab. Opened from cold state
  deliberately: the handle-based self-checks can't answer until the account's
  profile has loaded, and a link tapped from outside the app arrives inside that
  window.
- `vanity_deeplink_tip.yaml` — a brand-new account follows the same link and
  gets that handle's owner card, asserted on the `@handle` drawn under the name.
  The name alone would only prove some card opened. Tagged `creates-account`,
  which the runner excludes by default.
@github-actions github-actions Bot added the type: feature New functionality label Aug 26, 2026
…ccount row

`pathAdvancedPattern` is a case-sensitive PatternMatcher glob, so
`/[a-z0-9_]{2,15}` doesn't match `flipcash.com/Sally_Streamer` — the link opens
in the browser on API 31+ and the app's own lowercasing in `isVanityProfile`
never runs. Below 31 the attribute is ignored and the same link works, so one
handle behaved two ways across the minSdk range. A-Z in the set closes that;
mixed-case website pages it now also claims (`/Download`) classify as unrouted
and bounce back out via `OpenExternally`, which is what already happens below 31.

The My Account default-state test still expected three rows, and `ChangeUsername`
made it four — the failing assertion on CI.
@bmc08gt bmc08gt self-assigned this Aug 26, 2026
`reservedVanityPaths` held eight of the website's pages; the AASA served from
`flipcash.com/.well-known/apple-app-site-association` excludes twenty. The ten
handle-shaped ones it had and this list didn't — /app, /api, /assets, /fonts,
/icons, /js, /pool, /v1, /wallet, /currencycreator — were captured by the App
Link filter, classified as vanity profiles, looked up over the wire, and
dead-ended on "username not found" instead of opening the page.

The two lists are the same statement made twice, so the test now walks the
AASA's handle-shaped excludes rather than spot-checking three of them.
@bmc08gt
bmc08gt merged commit 0b8218c into code/cash Aug 26, 2026
3 checks passed
@bmc08gt
bmc08gt deleted the feat/username-ui branch August 27, 2026 15:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: deeplinks Deep link handling, URL routing, and link parsing area: network gRPC, connectivity, API, exchange rates area: notifications Push notifications, in-app messaging area: payments Payments, transfers, intents, billing area: scanner QR/Kikcode scanning, camera area: session area: tokens Token accounts, balances, token info type: feature New functionality

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant