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
53 changes: 53 additions & 0 deletions apps/flipcash/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,17 @@
<data android:host="phantom.app" />
</intent>

<!--
Browsers. Needed to name one explicitly when handing back a flipcash.com link the app
captured but doesn't route (DeeplinkAction.OpenExternally): we are a verified handler
for that host, so an unnamed VIEW intent would resolve to us and loop.
-->
<intent>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
</intent>

<intent>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.APP_MESSAGING" />
Expand Down Expand Up @@ -197,6 +208,48 @@
android:scheme="https" />
</intent-filter>

<!--
Vanity profile links: `flipcash.com/{username}` opens that user's tip card.

Usernames 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
before the reserved-path check, so those classify as unrouted and bounce straight
back out via DeeplinkAction.OpenExternally.

Verification needs assetlinks.json served from https://flipcash.com/.well-known/,
not only from the app./send. subdomains.
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />

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

<data
android:host="flipcash.com"
android:pathAdvancedPattern="/[a-zA-Z0-9_]{2,15}"
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" />
</intent-filter>

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

Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
package com.flipcash.app.internal.ui

import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.SharedTransitionLayout
Expand Down Expand Up @@ -49,6 +53,8 @@ import com.flipcash.app.core.verification.email.LocalEmailCodeChannel
import com.flipcash.app.featureflags.FeatureFlag
import com.flipcash.app.featureflags.LocalFeatureFlags
import com.flipcash.app.featureflags.model.BackgroundResetTimeout
import androidx.core.net.toUri
import com.flipcash.app.MainActivity
import com.flipcash.app.internal.ui.navigation.AppContent
import com.flipcash.app.internal.ui.navigation.NewAppContent
import com.flipcash.app.internal.ui.navigation.appEntryProvider
Expand All @@ -61,6 +67,7 @@ import com.flipcash.app.theme.FlipcashTheme
import com.flipcash.features.shareapp.R
import com.flipcash.services.user.AuthState
import com.getcode.animation.LocalSharedTransitionScope
import com.getcode.utils.trace
import com.getcode.libs.biometrics.BiometricsError
import com.getcode.libs.qr.rememberQrBitmapPainter
import com.getcode.navigation.AppNavHost
Expand Down Expand Up @@ -200,7 +207,9 @@ internal fun App(
is DeeplinkAction.OpenCashLink ->
session.openCashLink(action.entropy)
is DeeplinkAction.PresentTipCard ->
session.resolveTipCard(action.userId)
session.resolveTipCard(action.owner)
is DeeplinkAction.OpenExternally ->
context.openInBrowser(action.url)
is DeeplinkAction.Login ->
viewModel.handleLoginEntropy(
action.entropy,
Expand Down Expand Up @@ -231,7 +240,9 @@ internal fun App(
is DeeplinkAction.OpenCashLink ->
session.openCashLink(action.entropy)
is DeeplinkAction.PresentTipCard ->
session.resolveTipCard(action.userId)
session.resolveTipCard(action.owner)
is DeeplinkAction.OpenExternally ->
context.openInBrowser(action.url)
is DeeplinkAction.Login ->
viewModel.handleLoginEntropy(
action.entropy,
Expand Down Expand Up @@ -338,7 +349,10 @@ internal fun App(
onDismissed = { }
)

is DeeplinkAction.PresentTipCard -> session.resolveTipCard(action.userId)
is DeeplinkAction.PresentTipCard ->
session.resolveTipCard(action.owner)
is DeeplinkAction.OpenExternally ->
context.openInBrowser(action.url)
is DeeplinkAction.OpenCashLink -> session.openCashLink(
action.entropy
)
Expand Down Expand Up @@ -451,3 +465,38 @@ private fun BackgroundResetEffect(
}
}

/**
* Hand a link back to the web — the tail of [DeeplinkAction.OpenExternally].
*
* Not `ChromeTabsUtils.launchUrl`, and not `LocalUriHandler`: both send a package-less `ACTION_VIEW`,
* and this URL is on a host we are a verified handler for, so it would resolve straight back to us
* and the tap would loop. The browser has to be named. Resolving the default one keeps the hop
* invisible, which is what a tap on `flipcash.com/download` should feel like; when there isn't one to
* name — no default set, or the resolver activity answered — the chooser does it instead, with our
* own activity excluded so it can't be picked.
*/
private fun Context.openInBrowser(url: String) {
val uri = url.toUri()
val view = Intent(Intent.ACTION_VIEW, uri).addCategory(Intent.CATEGORY_BROWSABLE)

// Probed against a host we make no claim on, so the answer is a browser rather than ourselves.
val probe = Intent(Intent.ACTION_VIEW, "https://example.com".toUri())
.addCategory(Intent.CATEGORY_BROWSABLE)
val browser = packageManager
.resolveActivity(probe, PackageManager.MATCH_DEFAULT_ONLY)
?.activityInfo
?.packageName
?.takeIf { it != packageName && it != "android" }

val intent = if (browser != null) {
view.setPackage(browser)
} else {
Intent.createChooser(view, null).putExtra(
Intent.EXTRA_EXCLUDE_COMPONENTS,
arrayOf(ComponentName(this, MainActivity::class.java)),
)
}.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)

runCatching { startActivity(intent) }
.onFailure { trace(tag = "Deeplink", message = "No browser to open $url", error = it) }
}
Original file line number Diff line number Diff line change
Expand Up @@ -251,6 +251,7 @@ internal fun buildNavGraphForLaunch(

is DeeplinkAction.OpenCashLink,
is DeeplinkAction.PresentTipCard,
is DeeplinkAction.OpenExternally,
is DeeplinkAction.Login -> LaunchNavGraph(
baseRoutes = listOf(home),
pendingAction = action,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,13 +149,16 @@ sealed interface AppRoute : NavKey, Parcelable {
val nameSource: DisplayNameSource,
val includeName: Boolean = true,
val includePhoto: Boolean = true,
// Off by default: the username step is gated on a minimum balance and is never part of
// onboarding, so only the surfaces that qualify the account ask for it.
val includeUsername: Boolean = false,
val target: AppRoute? = null,
// When false, the first step has no back affordance and system back is swallowed —
// used in onboarding where display-name entry is a mandatory, non-dismissable step.
val allowBack: Boolean = true,
): AppRoute, FlowRouteWithResult<UpdateProfileResult> {
override val initialStack: List<NavKey>
get() = buildUpdateUserProfileStack(includeName, includePhoto)
get() = buildUpdateUserProfileStack(includeName, includeUsername, includePhoto)
}

@Serializable
Expand Down Expand Up @@ -360,13 +363,15 @@ private fun buildVerificationInitialStack(
return emptyList()
}

// Ordered list of the steps the flow should walk (via FlowNavigator.proceed()) — name first, then
// photo. In edit mode only the requested step(s) are included.
// Ordered list of the steps the flow should walk (via FlowNavigator.proceed()) — name, then
// username, then photo. In edit mode only the requested step(s) are included.
private fun buildUpdateUserProfileStack(
includeName: Boolean,
includeUsername: Boolean,
includePhoto: Boolean,
): List<NavKey> = buildList {
if (includeName) add(UpdateProfileStep.Name)
if (includeUsername) add(UpdateProfileStep.Username)
if (includePhoto) add(UpdateProfileStep.Photo)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.flipcash.app.core.chat
import android.os.Parcelable
import com.flipcash.app.core.contacts.DeviceContact
import com.flipcash.services.models.UserProfile
import com.flipcash.services.models.handle
import com.getcode.opencode.model.core.ID
import kotlinx.parcelize.Parcelize

Expand All @@ -21,11 +22,22 @@ import kotlinx.parcelize.Parcelize
sealed interface ChatParticipant: Parcelable {
val displayName: String

/**
* The counterparty's public `@handle`, or null when there isn't one to show.
*
* Always null for a [Contact]: a `CONTACT_DM` is addressed by phone number, and the device
* contact carries no Flipcash identity to read a username off. A [TipUser] has one whenever they
* have claimed it.
*/
val handle: String?

data class Contact(val contact: DeviceContact) : ChatParticipant {
override val displayName: String get() = contact.displayName
override val handle: String? get() = null
}

data class TipUser(val userId: ID, val profile: UserProfile) : ChatParticipant {
override val displayName: String get() = profile.displayName
override val handle: String? get() = profile.handle
}
}
Original file line number Diff line number Diff line change
@@ -1,13 +1,19 @@
package com.flipcash.app.core.navigation

import com.getcode.opencode.model.core.ID
import com.flipcash.app.core.tipping.TipCardOwner
import com.getcode.solana.keys.Mint

sealed interface DeeplinkAction {
data class Navigate(val routes: List<com.flipcash.app.core.AppRoute>) : DeeplinkAction
data class Login(val entropy: String) : DeeplinkAction
data class OpenCashLink(val entropy: String) : DeeplinkAction
data class PresentTipCard(val userId: ID): 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.
*/
data class PresentTipCard(val owner: TipCardOwner): DeeplinkAction

/**
* A `/token/{mint}` link.
Expand All @@ -25,5 +31,15 @@ sealed interface DeeplinkAction {
val routes: List<com.flipcash.app.core.AppRoute>,
) : DeeplinkAction

/**
* A link the app captured but doesn't route — hand it back to the web.
*
* Only the bare `flipcash.com` host produces one. Its path space is shared with the website
* (/download, /privacy, /terms), and the App Link filter can only narrow it to the handle
* charset — which those words also satisfy, and which older platforms ignore entirely. Rather
* than dead-end the tap on the home screen, the URL goes to a browser.
*/
data class OpenExternally(val url: String) : DeeplinkAction

data object None : DeeplinkAction
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,13 @@ sealed interface DeeplinkType: Parcelable {

@Serializable data class Tipcard(val userId: ID): DeeplinkType

/**
* A vanity `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.
*/
@Serializable data class TipcardByUsername(val username: String): DeeplinkType

@Serializable
data class EmailVerification(
val email: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.flipcash.app.core.tipping

/**
* A tip card link turned out to address the account that followed it.
*
* Raised only by the resolve-by-handle path, and only after the round trip. Both earlier
* self-checks — `AppRouter`'s, and [com.flipcash.app.core.tipping.TipCardOwner.isSelf] in the
* session's tip card delegate — compare handles, which they can only do once this account's own
* profile has loaded. A link followed before that gets past both; the id the profile fetch answers
* with settles it.
*
* A failure rather than a card so the resolve stops short of its side effects — arming the tip
* modal, buzzing the phone — for a card that will never be tipped.
*/
class OwnTipCard(val username: String) :
IllegalStateException("@$username is the signed-in account's own handle")
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.flipcash.app.core.tipping

import com.getcode.opencode.model.core.ID

/**
* Who a tip card belongs to, in whichever of the two ways the holder of this value can name them.
*
* The fork exists because turning a handle into an id is a server round trip. Nothing between a
* `flipcash.com/{username}` link and the session can make that call, so the username travels
* un-resolved the whole way — through [com.flipcash.app.core.navigation.DeeplinkAction] and back
* out through [com.flipcash.app.core.util.Linkify]. Naming it once keeps every stop on that path
* from carrying its own parallel pair of "by id" / "by handle" entry points.
*/
sealed interface TipCardOwner {
/** By account id — how the app addresses a card everywhere except a vanity link. */
data class ById(val userId: ID) : TipCardOwner

/** By claimed public handle, unresolved. */
data class ByUsername(val username: String) : TipCardOwner

/**
* Whether this addresses the account signed in right now, which has no card to present to
* itself — tipping yourself is a payment no-op.
*
* Takes both identities rather than a profile, because they do not become available together:
* [accountId] is set the moment the account authenticates, while the handle arrives with the
* profile fetch. A single nullable profile would make a self-link by id stop matching in the
* window before its own profile loads.
*
* Handles are lowercase on the wire, but a link can be typed or pasted in any case.
*/
fun isSelf(accountId: ID?, username: String?): Boolean = when (this) {
is ById -> accountId != null && userId == accountId
is ByUsername -> this.username.equals(username, ignoreCase = true)
}

companion object {
/**
* A card's preferred public address: the handle when the account has claimed one, the id
* when it hasn't.
*
* The precedence is as much a display decision as a routing one — the You tab shows
* `flipcash.com/<username>` under the code (node 9442:3673), so sharing or copying anything
* else would hand out a second, unrecognisable address for a card that names itself once.
*/
fun preferringUsername(username: String?, userId: ID): TipCardOwner =
username?.takeIf { it.isNotBlank() }?.let(::ByUsername) ?: ById(userId)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,18 @@ sealed interface UpdateProfileStep : FlowStep, Parcelable {
@Serializable
object Name : UpdateProfileStep

/**
* Claiming the public `@handle`. Optional and off by default: unlike the display name it is
* never part of onboarding — the server gates it behind a minimum balance, so it is reached
* from My Account or the "You" tab once the account qualifies.
*/
@Parcelize
@Serializable
object Username : UpdateProfileStep

@Parcelize
@Serializable
object Photo : UpdateProfileStep


}
}
Loading
Loading