Skip to content

Commit dbcd496

Browse files
authored
fix(deeplinks): route a jump link that carries its own path (#1367)
* fix(payments): raise the insufficient-balance prompt above the bill overlay The prompt was drawn by NavMessagingEntryDecorator, which sat inside NavBillOverlayEntryDecorator; the outermost decorator paints its overlay last, so the tip card always covered the prompt it had just raised. Moving the messaging decorator outermost puts a prompt that has to be answered above everything the entry draws. The in-chat send raised a red alert for the same condition. It is the same situation the tip card handles as info with an Add Money action, so it now does too. * fix(deeplinks): route a jump link that carries its own path The website's tip interstitial sends "Open in Flipcash" to jump.flipcash.com/tip/{id}, with no `#source=` fragment. classifyOrThrow read the jump host as a wrapper and nothing else, so unwrapJumpTarget returned null and the link fell through to unrouted() — which hands only flipcash.com back to the browser and returns None for every other host. The tap brought the app to the front and nothing happened. Classify a jump link by its own path when there is no fragment to unwrap. The redirector is claimed whole by the manifest and mirrors the app's path space, so /tip/{id} resolves the same as it does on app.flipcash.com. A `#source=` fragment still takes precedence, and isProfileLink stays host-gated to flipcash.com, so the redirector does not start claiming handles.
1 parent 4065ad9 commit dbcd496

4 files changed

Lines changed: 95 additions & 15 deletions

File tree

  • apps/flipcash
    • app/src/main/kotlin/com/flipcash/app/internal/ui/navigation
    • features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal
    • shared/router/src

‎apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppContent.kt‎

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,18 @@ internal fun AppContent(
102102
navigator = codeNavigator,
103103
resultStateRegistry = resultStateRegistry,
104104
decorators = listOf(
105-
// First = outermost: bill draws above screen content but is skipped for sheet
106-
// entries, so NavDisplay paints the sheet scene above the bill-bearing base
107-
// entry — sheets open OVER the bill. See NavBillOverlayEntryDecorator.
108-
rememberNavBillOverlayEntryDecorator(),
105+
// First = outermost, and outermost draws last: a bottom bar message is a prompt
106+
// that has to be answered, so it sits above everything the entry draws — the
107+
// bill overlay and its scrim included (the tip card's own modal would otherwise
108+
// cover the insufficient-balance prompt raised from it).
109109
rememberNavMessagingEntryDecorator(
110110
codeNavigator.backStack,
111111
barManager
112112
),
113+
// The bill draws above screen content but is skipped for sheet entries, so
114+
// NavDisplay paints the sheet scene above the bill-bearing base entry — sheets
115+
// open OVER the bill. See NavBillOverlayEntryDecorator.
116+
rememberNavBillOverlayEntryDecorator(),
113117
rememberNavBlockingOverlayEntryDecorator(),
114118
// Inset-only: reserves LocalTabBarPadding per tab-home entry for the hoisted bar,
115119
// so pushed/detail screens are not inset and the inset never collapses mid-push.

‎apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt‎

Lines changed: 21 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -833,10 +833,7 @@ internal class ChatViewModel @Inject constructor(
833833
val balanceInLocal = balance.convertingTo(rate)
834834
val isOverBalance = entered.valueGreaterThan(balanceInLocal)
835835
if (isOverBalance) {
836-
BottomBarManager.showAlert(
837-
resources.getString(R.string.error_title_insufficientFunds),
838-
resources.getString(R.string.error_description_insufficientFunds),
839-
)
836+
presentInsufficientBalance()
840837
}
841838
return isOverBalance
842839
}
@@ -874,6 +871,26 @@ internal class ChatViewModel @Inject constructor(
874871
}
875872
}
876873

874+
/**
875+
* Over balance, with something in the account: the same prompt the tip card raises, offering
876+
* the way out of it. [presentAddMoney] covers the empty account, which has nothing to enter a
877+
* smaller amount than.
878+
*/
879+
private fun presentInsufficientBalance() {
880+
BottomBarManager.showInfo(
881+
title = resources.getString(R.string.title_insufficientBalance),
882+
message = resources.getString(R.string.description_insufficientBalanceToUse),
883+
actions = listOf(
884+
BottomBarAction(
885+
text = resources.getString(R.string.action_addMoney)
886+
) {
887+
dispatchEvent(Event.PresentDepositOptions)
888+
},
889+
),
890+
showCancel = true,
891+
)
892+
}
893+
877894
private fun presentAddMoney() {
878895
BottomBarManager.showInfo(
879896
title = resources.getString(R.string.title_noBalanceYet),

‎apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt‎

Lines changed: 24 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -171,13 +171,31 @@ internal class AppRouter(
171171
}
172172

173173
private fun classifyOrThrow(deepLink: DeepLink): DeeplinkType? {
174-
return when {
175-
// A jump link is a wrapper, never a destination. Unwrap once and classify the inner
176-
// URL; a jump pointing at another jump is malformed and drops to null rather than
177-
// recursing. Mirrors iOS DeepLinkController.
178-
deepLink.isJump() -> deepLink.unwrapJumpTarget()
174+
// A jump link may carry the real URL percent-encoded in `#source=`. Unwrap once and
175+
// classify that; a jump pointing at another jump is malformed and falls through rather
176+
// than recursing. Mirrors iOS DeepLinkController.
177+
if (deepLink.isJump()) {
178+
deepLink.unwrapJumpTarget()
179179
?.takeUnless { it.isJump() }
180-
?.let { classifyOrThrow(it) }
180+
?.let { return classifyOrThrow(it) }
181+
}
182+
return deepLink.classifyByPath()
183+
}
184+
185+
/**
186+
* Classify a link by its path. Every route below is matched on path alone except
187+
* [DeepLink.isProfileLink], which is host-gated to [PROFILE_HOST] because only the bare host
188+
* gives a person's handle a whole path segment.
189+
*
190+
* That is why an unwrappable jump link lands here too: [JUMP_HOST] is a redirector the app
191+
* claims whole, and it mirrors the app's path space rather than owning one of its own. The
192+
* website's tip interstitial sends `jump.flipcash.com/tip/{id}` with no fragment at all, and
193+
* a fragment-only reading would drop it — the link opens the app and nothing happens, since
194+
* [unrouted] hands only [PROFILE_HOST] back to the browser.
195+
*/
196+
private fun DeepLink.classifyByPath(): DeeplinkType? {
197+
val deepLink = this
198+
return when {
181199
deepLink.isLogin() -> deepLink.handleLoginLink()
182200
deepLink.isCashLink() -> deepLink.handleCashLink()
183201
deepLink.isToken() -> deepLink.handleTokenLink()

‎apps/flipcash/shared/router/src/test/kotlin/com/flipcash/app/router/internal/AppRouterTest.kt‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -866,12 +866,53 @@ class AppRouterTest {
866866
}
867867

868868
@Test
869-
fun `jump link with no source fragment classifies to null`() {
869+
fun `jump link with no source fragment and no path classifies to null`() {
870870
assertNull(router.classify(DeepLink("https://jump.flipcash.com/")))
871871
assertNull(router.classify(DeepLink("https://jump.flipcash.com/#other=1")))
872872
assertNull(router.classify(DeepLink("https://jump.flipcash.com/#source=")))
873873
}
874874

875+
@Test
876+
fun `jump link carrying a route on its own path is classified by that path`() {
877+
// What the website's tip interstitial navigates to from "Open in Flipcash" — a bare
878+
// redirector path, no `#source=` fragment.
879+
val userId = "11111111-1111-1111-1111-111111111111"
880+
val type = router.classify(DeepLink("https://jump.flipcash.com/tip/$userId"))
881+
882+
assertIs<DeeplinkType.Tipcard>(type)
883+
assertEquals(UUID.fromString(userId).bytes, type.userId)
884+
}
885+
886+
@Test
887+
fun `jump link path routes dispatch like the same path on an app host`() {
888+
loggedIn()
889+
currentUserId = UUID.fromString("22222222-2222-2222-2222-222222222222").bytes
890+
891+
val userId = "11111111-1111-1111-1111-111111111111"
892+
val action = router.dispatch(DeepLink("https://jump.flipcash.com/tip/$userId"))
893+
894+
assertIs<DeeplinkAction.PresentTipCard>(action)
895+
assertEquals(TipCardOwner.ById(UUID.fromString(userId).bytes), action.owner)
896+
}
897+
898+
@Test
899+
fun `a source fragment still wins over the jump link's own path`() {
900+
val type = router.classify(
901+
DeepLink(
902+
"https://jump.flipcash.com/tip/11111111-1111-1111-1111-111111111111#source=" +
903+
URLEncoder.encode("https://app.flipcash.com/token/$MINT", "UTF-8")
904+
)
905+
)
906+
assertIs<DeeplinkType.TokenInfo>(type)
907+
}
908+
909+
@Test
910+
fun `jump link does not claim a handle the way the bare host does`() {
911+
// isProfileLink is host-gated: only flipcash.com gives a person a whole path segment,
912+
// so `jump.flipcash.com/sally` is not a tip card by handle.
913+
assertNull(router.classify(DeepLink("https://jump.flipcash.com/sally_streamer")))
914+
}
915+
875916
@Test
876917
fun `jump link wrapping an unroutable url classifies to null`() {
877918
assertNull(router.classify(jump("https://app.flipcash.com/chat/abc")))

0 commit comments

Comments
 (0)