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
Original file line number Diff line number Diff line change
Expand Up @@ -102,14 +102,18 @@ internal fun AppContent(
navigator = codeNavigator,
resultStateRegistry = resultStateRegistry,
decorators = listOf(
// First = outermost: bill draws above screen content but is skipped for sheet
// entries, so NavDisplay paints the sheet scene above the bill-bearing base
// entry — sheets open OVER the bill. See NavBillOverlayEntryDecorator.
rememberNavBillOverlayEntryDecorator(),
// First = outermost, and outermost draws last: a bottom bar message is a prompt
// that has to be answered, so it sits above everything the entry draws — the
// bill overlay and its scrim included (the tip card's own modal would otherwise
// cover the insufficient-balance prompt raised from it).
rememberNavMessagingEntryDecorator(
codeNavigator.backStack,
barManager
),
// The bill draws above screen content but is skipped for sheet entries, so
// NavDisplay paints the sheet scene above the bill-bearing base entry — sheets
// open OVER the bill. See NavBillOverlayEntryDecorator.
rememberNavBillOverlayEntryDecorator(),
rememberNavBlockingOverlayEntryDecorator(),
// Inset-only: reserves LocalTabBarPadding per tab-home entry for the hoisted bar,
// so pushed/detail screens are not inset and the inset never collapses mid-push.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -833,10 +833,7 @@ internal class ChatViewModel @Inject constructor(
val balanceInLocal = balance.convertingTo(rate)
val isOverBalance = entered.valueGreaterThan(balanceInLocal)
if (isOverBalance) {
BottomBarManager.showAlert(
resources.getString(R.string.error_title_insufficientFunds),
resources.getString(R.string.error_description_insufficientFunds),
)
presentInsufficientBalance()
}
return isOverBalance
}
Expand Down Expand Up @@ -874,6 +871,26 @@ internal class ChatViewModel @Inject constructor(
}
}

/**
* Over balance, with something in the account: the same prompt the tip card raises, offering
* the way out of it. [presentAddMoney] covers the empty account, which has nothing to enter a
* smaller amount than.
*/
private fun presentInsufficientBalance() {
BottomBarManager.showInfo(
title = resources.getString(R.string.title_insufficientBalance),
message = resources.getString(R.string.description_insufficientBalanceToUse),
actions = listOf(
BottomBarAction(
text = resources.getString(R.string.action_addMoney)
) {
dispatchEvent(Event.PresentDepositOptions)
},
),
showCancel = true,
)
}

private fun presentAddMoney() {
BottomBarManager.showInfo(
title = resources.getString(R.string.title_noBalanceYet),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,31 @@ internal class AppRouter(
}

private fun classifyOrThrow(deepLink: DeepLink): DeeplinkType? {
return when {
// A jump link is a wrapper, never a destination. Unwrap once and classify the inner
// URL; a jump pointing at another jump is malformed and drops to null rather than
// recursing. Mirrors iOS DeepLinkController.
deepLink.isJump() -> deepLink.unwrapJumpTarget()
// A jump link may carry the real URL percent-encoded in `#source=`. Unwrap once and
// classify that; a jump pointing at another jump is malformed and falls through rather
// than recursing. Mirrors iOS DeepLinkController.
if (deepLink.isJump()) {
deepLink.unwrapJumpTarget()
?.takeUnless { it.isJump() }
?.let { classifyOrThrow(it) }
?.let { return classifyOrThrow(it) }
}
return deepLink.classifyByPath()
}

/**
* Classify a link by its path. Every route below is matched on path alone except
* [DeepLink.isProfileLink], which is host-gated to [PROFILE_HOST] because only the bare host
* gives a person's handle a whole path segment.
*
* That is why an unwrappable jump link lands here too: [JUMP_HOST] is a redirector the app
* claims whole, and it mirrors the app's path space rather than owning one of its own. The
* website's tip interstitial sends `jump.flipcash.com/tip/{id}` with no fragment at all, and
* a fragment-only reading would drop it — the link opens the app and nothing happens, since
* [unrouted] hands only [PROFILE_HOST] back to the browser.
*/
private fun DeepLink.classifyByPath(): DeeplinkType? {
val deepLink = this
return when {
deepLink.isLogin() -> deepLink.handleLoginLink()
deepLink.isCashLink() -> deepLink.handleCashLink()
deepLink.isToken() -> deepLink.handleTokenLink()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -866,12 +866,53 @@ class AppRouterTest {
}

@Test
fun `jump link with no source fragment classifies to null`() {
fun `jump link with no source fragment and no path classifies to null`() {
assertNull(router.classify(DeepLink("https://jump.flipcash.com/")))
assertNull(router.classify(DeepLink("https://jump.flipcash.com/#other=1")))
assertNull(router.classify(DeepLink("https://jump.flipcash.com/#source=")))
}

@Test
fun `jump link carrying a route on its own path is classified by that path`() {
// What the website's tip interstitial navigates to from "Open in Flipcash" — a bare
// redirector path, no `#source=` fragment.
val userId = "11111111-1111-1111-1111-111111111111"
val type = router.classify(DeepLink("https://jump.flipcash.com/tip/$userId"))

assertIs<DeeplinkType.Tipcard>(type)
assertEquals(UUID.fromString(userId).bytes, type.userId)
}

@Test
fun `jump link path routes dispatch like the same path on an app host`() {
loggedIn()
currentUserId = UUID.fromString("22222222-2222-2222-2222-222222222222").bytes

val userId = "11111111-1111-1111-1111-111111111111"
val action = router.dispatch(DeepLink("https://jump.flipcash.com/tip/$userId"))

assertIs<DeeplinkAction.PresentTipCard>(action)
assertEquals(TipCardOwner.ById(UUID.fromString(userId).bytes), action.owner)
}

@Test
fun `a source fragment still wins over the jump link's own path`() {
val type = router.classify(
DeepLink(
"https://jump.flipcash.com/tip/11111111-1111-1111-1111-111111111111#source=" +
URLEncoder.encode("https://app.flipcash.com/token/$MINT", "UTF-8")
)
)
assertIs<DeeplinkType.TokenInfo>(type)
}

@Test
fun `jump link does not claim a handle the way the bare host does`() {
// isProfileLink is host-gated: only flipcash.com gives a person a whole path segment,
// so `jump.flipcash.com/sally` is not a tip card by handle.
assertNull(router.classify(DeepLink("https://jump.flipcash.com/sally_streamer")))
}

@Test
fun `jump link wrapping an unroutable url classifies to null`() {
assertNull(router.classify(jump("https://app.flipcash.com/chat/abc")))
Expand Down
Loading