From 440e73dcf7a12c5c56911ce2467b30a3835fba6a Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Wed, 5 Aug 2026 11:57:48 -0400 Subject: [PATCH] feat: remove send tab, default tips on, drop tipping beta flag Remove the Send *tab* (nav button), launch tipping as an always-on feature (dropping the Tipping beta flag), and stop handling chat deeplinks. Send tab - Delete NavBarButton.Send, the NavigationBar button + preview, ScannerDecorItem.Send + the scanner mapping. - Disconnect the direct-send flow from the build: remove AppRoute.Sheets.Send, the nav-graph entry, the settings.gradle.kts include, and the app module dep. The direct-send feature module (and core/send/SendStep.kt) are kept on disk but no longer built, so the flow can be re-enabled later by re-wiring it. - Rework NavBarConfigTest for the new default order [Discover, Give, Tips, Wallet] and add a regression test that a persisted order containing the removed "Send" token deserializes cleanly (dropped + back-filled), not crash. Tipping (launched) - Delete FeatureFlag.Tipping (definition + title/message); tips are on for all users. Remove the session observer and isTippingEnabled state, and strip the now-orphaned FeatureFlagController dependency from TippingCoordinator. The Tips tab is always shown. Chat deeplinks - Remove DeeplinkType.Chat and isChat/handleChat from AppRouter. /chat/ links now fall through unhandled and land on the camera (documented so the routing isn't re-added by accident). Tip DMs still route via /tip/chat/. - Fold non-tip and contact-addressed chat push notifications into a plain launch intent; delete the now-dead Linkify.chatById/chatByPhone. Maestro - Delete direct_send.yaml and send_to_contact.yaml (tapped the removed tab), drop the dead tipping_enabled beta-flag args (tipping is default-on), and update the README. --- apps/flipcash/app/build.gradle.kts | 1 - .../kotlin/com/flipcash/app/MainActivity.kt | 4 +-- .../ui/navigation/AppScreenContent.kt | 2 -- .../kotlin/com/flipcash/app/core/AppRoute.kt | 10 ------- .../app/core/navigation/DeeplinkType.kt | 2 -- .../app/core/navigation/NavBarButton.kt | 3 +- .../com/flipcash/app/core/ui/NavigationBar.kt | 20 ------------- .../com/flipcash/app/core/util/Linkify.kt | 2 -- .../app/core/navigation/NavBarConfigTest.kt | 25 +++++++++++----- .../app/scanner/internal/ScannerDecorItem.kt | 1 - .../ui/components/ScannerNavigationBar.kt | 17 +---------- .../tipping/internal/screens/TipInfoScreen.kt | 2 -- .../flipcash/app/featureflags/FeatureFlag.kt | 12 -------- .../app/notifications/NotificationService.kt | 18 ++++++------ .../flipcash/app/router/internal/AppRouter.kt | 29 +++---------------- .../flipcash/app/session/SessionController.kt | 1 - .../session/internal/RealSessionController.kt | 4 --- .../session/internal/SessionStateHolder.kt | 5 ++-- .../internal/SessionStateHolderTest.kt | 5 ++-- .../shared/tipping/TippingCoordinator.kt | 8 ----- .../shared/tipping/TippingCoordinatorTest.kt | 28 ------------------ maestro/README.md | 21 +++++--------- maestro/blocking.yaml | 4 +-- maestro/direct_send.yaml | 29 ------------------- maestro/send_to_contact.yaml | 28 ------------------ maestro/tip_chat.yaml | 5 ++-- maestro/tip_deeplink.yaml | 8 ++--- maestro/tipping_setup.yaml | 7 ++--- settings.gradle.kts | 1 - 29 files changed, 57 insertions(+), 245 deletions(-) delete mode 100644 maestro/direct_send.yaml delete mode 100644 maestro/send_to_contact.yaml diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index a1f002fa5..97ed76506 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -227,7 +227,6 @@ dependencies { implementation(project(":apps:flipcash:features:transactions")) implementation(project(":apps:flipcash:features:bill-customization")) implementation(project(":apps:flipcash:features:currency-creator")) - implementation(project(":apps:flipcash:features:direct-send")) implementation(project(":apps:flipcash:features:messenger")) implementation(project(":apps:flipcash:features:invite")) implementation(project(":apps:flipcash:features:discovery")) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt index 2ed746a1f..77f4e6fec 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt @@ -192,12 +192,12 @@ class MainActivity : FragmentActivity() { /** * Test-only: enable beta flags passed as a launch argument, so flag-gated features - * (tipping, blocklist, …) can be exercised in UI tests without toggling them in the + * (blocklist, …) can be exercised in UI tests without toggling them in the * Labs UI. Mirrors iOS's `--beta-flags`. Debug/UI-test builds only. * * launchApp: * arguments: - * betaFlags: "tipping_enabled,blocklist_enabled" + * betaFlags: "blocklist_enabled" * * The value is a comma-separated list of [FeatureFlag.key]s. */ diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 01a33f4cf..15bc64f1d 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -32,7 +32,6 @@ import com.flipcash.app.core.AppRoute import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.currency.RegionSelectionScreen import com.flipcash.app.deposit.DepositFlowScreen -import com.flipcash.app.directsend.SendFlowScreen import com.flipcash.app.invite.InviteContactScreen import com.flipcash.app.messenger.ChatFlowScreen import com.flipcash.app.discovery.TokenDiscoveryScreen @@ -94,7 +93,6 @@ fun appEntryProvider( // Sheets (inner content — wrapped in Main.Sheet by navigateTo()) // Route type is `Give` but the screen is the Cash/Give screen the flows call cash_screen. annotatedEntry(testTag = "cash_screen") { key -> CashScreen(key.mint, key.fromTokenInfo) } - annotatedEntry { SendFlowScreen(resultStateRegistry = resultStateRegistry) } annotatedEntry { key -> TippingFlowScreen(route = key, resultStateRegistry = resultStateRegistry) } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 29838affb..173b56d33 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -160,16 +160,6 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class Give(val mint: Mint? = null, val fromTokenInfo: Boolean = false) : Sheets - /** - * Direct send flow — phone-verified user picks a contact and sends funds. - * - * @param resumed `true` when the flow is re-entered after an interrupting gate - * (e.g. phone verification). A distinct value produces a new route instance so - * Nav3 treats `replaceAll` as a forward push instead of a pop. - */ - @Serializable - data class Send(val resumed: Boolean = false): Sheets - @Serializable data class Tips(val resumed: Boolean = false): Sheets { } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt index 51f788695..296768c33 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/DeeplinkType.kt @@ -18,8 +18,6 @@ sealed interface DeeplinkType: Parcelable { @Serializable data class TokenInfo(val mint: Mint): DeeplinkType, Navigatable - @Serializable data class Chat(val identifier: ChatIdentifier): DeeplinkType, Navigatable - @Serializable data class TipChat(val identifier: ChatIdentifier): DeeplinkType, Navigatable @Serializable data class Tipcard(val userId: ID): DeeplinkType diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt index 7801fbb7b..dcd3ee99d 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/navigation/NavBarButton.kt @@ -4,11 +4,10 @@ enum class NavBarButton { Give, Wallet, Discover, - Send, Tips, ; companion object { - val defaultOrder = listOf(Discover, Give, Send, Tips, Wallet,) + val defaultOrder = listOf(Discover, Give, Tips, Wallet,) } } diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt index ac9b3c37d..419001637 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/NavigationBar.kt @@ -147,15 +147,6 @@ fun NavigationBar( onClick = { onButtonClick(NavBarButton.Discover) } ) - NavBarButton.Send -> BottomBarAction( - modifier = buttonModifier, - label = stringResource(R.string.action_send), - badgeCount = state.contactDmUnreadCount, - painter = painterResource(R.drawable.ic_send_outlined), - imageSize = imageSize, - onClick = { onButtonClick(NavBarButton.Send) } - ) - NavBarButton.Tips -> BottomBarAction( modifier = buttonModifier, label = stringResource(R.string.action_tips), @@ -320,14 +311,3 @@ private fun NavigationBarPreview() { state = NavigationBarState(contactDmUnreadCount = 100), ) } -@Preview -@PreviewWrapper(FlipcashThemeWrapper::class) -@Composable -private fun SendActionPreview() { - BottomBarAction( - painter = painterResource(R.drawable.ic_send_outlined), - label = "Send", - badgeCount = 100, - onClick = null, - ) -} diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt index 49bb0b6ba..5a86d4c6c 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/util/Linkify.kt @@ -19,7 +19,5 @@ object Linkify { fun tweet(message: String): String = "https://www.twitter.com/intent/tweet?text=${message.urlEncode()}" fun tokenInfo(token: Token): String = tokenInfo(token.address) fun tokenInfo(mint: Mint): String = "https://app.flipcash.com/token/${mint.base58()}" - fun chatById(chatId: ChatId): String = "https://app.flipcash.com/chat/${chatId.bytes.encodeBase64(urlSafe = true)}" fun tipChatById(chatId: ChatId): String = "https://app.flipcash.com/tip/chat/${chatId.bytes.encodeBase64(urlSafe = true)}" - fun chatByPhone(phoneNumber: String): String = "https://app.flipcash.com/chat/${phoneNumber.urlEncode()}" } \ No newline at end of file diff --git a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarConfigTest.kt b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarConfigTest.kt index 751b9835f..0b3cb7426 100644 --- a/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarConfigTest.kt +++ b/apps/flipcash/core/src/test/kotlin/com/flipcash/app/core/navigation/NavBarConfigTest.kt @@ -14,7 +14,7 @@ class NavBarConfigTest { @Test fun `round-trips a full, current order`() { val config = NavBarConfig( - order = listOf(NavBarButton.Wallet, NavBarButton.Give, NavBarButton.Discover, NavBarButton.Send, NavBarButton.Tips), + order = listOf(NavBarButton.Wallet, NavBarButton.Give, NavBarButton.Discover, NavBarButton.Tips), giveButtonLabel = GiveButtonLabel.Cash, ) assertEquals(config, NavBarConfig.deserialize(config.serialize())) @@ -23,39 +23,50 @@ class NavBarConfigTest { @Test fun `back-fills a button added after the order was persisted, at its default position`() { // A nav bar order persisted before Tips was added to the enum/defaultOrder. - val legacy = "Discover,Give,Send,Wallet|Cash" + val legacy = "Discover,Give,Wallet|Cash" val order = NavBarConfig.deserialize(legacy).order assertTrue(NavBarButton.Tips in order, "Tips should be back-filled into a legacy order") - // Inserted at its defaultOrder position (between Send and Wallet), not appended. + // Inserted at its defaultOrder position (between Give and Wallet), not appended. assertEquals( - listOf(NavBarButton.Discover, NavBarButton.Give, NavBarButton.Send, NavBarButton.Tips, NavBarButton.Wallet), + listOf(NavBarButton.Discover, NavBarButton.Give, NavBarButton.Tips, NavBarButton.Wallet), order, ) } @Test fun `back-fill preserves a user's custom ordering of existing buttons`() { - val legacy = "Wallet,Send,Give,Discover|Cash" + val legacy = "Wallet,Give,Discover|Cash" val order = NavBarConfig.deserialize(legacy).order assertTrue(NavBarButton.Tips in order) // Existing buttons keep the user's reversed order; only the missing one is added. assertEquals( - listOf(NavBarButton.Wallet, NavBarButton.Send, NavBarButton.Give, NavBarButton.Discover), + listOf(NavBarButton.Wallet, NavBarButton.Give, NavBarButton.Discover), order.filterNot { it == NavBarButton.Tips }, ) } @Test fun `unknown button names are dropped and missing known ones back-filled`() { - val order = NavBarConfig.deserialize("Discover,Bogus,Give,Send,Wallet|Cash").order + val order = NavBarConfig.deserialize("Discover,Bogus,Give,Wallet|Cash").order assertEquals(NavBarButton.defaultOrder, order) } + @Test + fun `a persisted order containing the removed Send button is dropped without crashing`() { + // Users who customized their nav bar before Send was removed have "Send" persisted in + // their NavBar config. deserialize() must silently drop the now-unknown token (never + // throw on NavBarButton.valueOf) and back-fill the current default order. + val order = NavBarConfig.deserialize("Discover,Give,Send,Tips,Wallet|Cash").order + + assertTrue("Send" !in order.map { it.name }, "the removed Send token must not survive") + assertEquals(NavBarButton.defaultOrder, order) + } + @Test fun `empty order falls back to the default order`() { val order = NavBarConfig.deserialize("|Cash").order diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt index ecd7c3283..0a254689d 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ScannerDecorItem.kt @@ -10,6 +10,5 @@ sealed class ScannerDecorItem(val screen: AppRoute) { data object Menu : ScannerDecorItem(AppRoute.Sheets.Menu) data object Logo: ScannerDecorItem(AppRoute.Sheets.ShareApp) data object Discover: ScannerDecorItem(AppRoute.Token.Discovery) - data object Send: ScannerDecorItem(AppRoute.Sheets.Send()) data object Tips: ScannerDecorItem(AppRoute.Sheets.Tips()) } \ No newline at end of file diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt index 1ada39ed1..c8eacf12a 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/ui/components/ScannerNavigationBar.kt @@ -31,23 +31,9 @@ internal fun ScannerNavigationBar( NavBarConfig.deserialize(navBarConfigString) } - val effectiveConfig = remember(config, state.isTippingEnabled) { - val buttons = config.order - .filter { option -> - when (option) { - NavBarButton.Tips -> state.isTippingEnabled - else -> true - } - } - - config.copy( - order = buttons, - ) - } - NavigationBar( modifier = modifier, - config = effectiveConfig, + config = config, state = NavigationBarState( contactDmUnreadCount = state.contactDmUnreadCount, tipUnreadCount = state.tipsUnreadCount, @@ -60,7 +46,6 @@ internal fun ScannerNavigationBar( NavBarButton.Give -> ScannerDecorItem.Give NavBarButton.Wallet -> ScannerDecorItem.Wallet NavBarButton.Discover -> ScannerDecorItem.Discover - NavBarButton.Send -> ScannerDecorItem.Send NavBarButton.Tips -> ScannerDecorItem.Tips } onAction(item) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt index 6cdf82d1e..6f8f102e1 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt @@ -19,8 +19,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper import com.flipcash.app.core.AppRoute import com.flipcash.app.core.LocalUserManager -import com.flipcash.app.core.send.SendResult -import com.flipcash.app.core.send.SendStep import com.flipcash.app.core.tipping.TipResult import com.flipcash.app.core.tipping.TipStep import com.flipcash.app.theme.FlipcashThemeWrapper diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 707aa5294..733acaa5c 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -129,16 +129,6 @@ sealed interface FeatureFlag { override val persistLogOut: Boolean = false } - @FeatureFlagMarker - data object Tipping: FeatureFlag { - override val key: String = "tipping_enabled" - override val default: Boolean = false - override val launched: Boolean = false - override val visible: Boolean = true - override val persistLogOut: Boolean = false - override val minTrack: FeatureTrack = FeatureTrack.Production - } - @FeatureFlagMarker data object FrostedTipCard: FeatureFlag { override val key: String = "frosted_tip_card_enabled" @@ -180,7 +170,6 @@ val FeatureFlag<*>.title: String FeatureFlag.NavBar -> "Navigation Bar" FeatureFlag.GiveUsdf -> "Give/Send USDF" FeatureFlag.ShowNetworkState -> "Network Offline Indicator" - FeatureFlag.Tipping -> "Tipping" FeatureFlag.FrostedTipCard -> "Frosted Tip Card" FeatureFlag.Blocklist -> "Blocklist" } @@ -197,7 +186,6 @@ val FeatureFlag<*>.message: String FeatureFlag.NavBar -> "Customize the order and labels of navigation bar buttons" FeatureFlag.GiveUsdf -> "When enabled, you'll gain the ability to send USDF directly and give it as cash" FeatureFlag.ShowNetworkState -> "When enabled, you'll gain the ability to see the network state on the Scanner when offline" - FeatureFlag.Tipping -> "When enabled, you'll gain the ability to tip other users and set up your own tip card to receive tips" FeatureFlag.FrostedTipCard -> "When enabled, the tip card in the scanner renders as frosted glass over a blurred snapshot of the camera instead of a solid card" FeatureFlag.Blocklist -> "When enabled, you'll gain the ability to open a chat participant's profile, block them, and manage your blocklist from My Account" } diff --git a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt index 9ea8950ae..2173f94c5 100644 --- a/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt +++ b/apps/flipcash/shared/notifications/src/main/kotlin/com/flipcash/app/notifications/NotificationService.kt @@ -421,16 +421,16 @@ class NotificationService : FirebaseMessagingService(), data = Linkify.tokenInfo(navigation.mint).toUri() } - is NavigationTrigger.Chat.ById -> Intent(Intent.ACTION_VIEW).apply { - data = if (metadata?.chatType == ChatType.TIP_DM) { - Linkify.tipChatById(navigation.chatId).toUri() - } else { - Linkify.chatById(navigation.chatId).toUri() + // Only tip DMs deep-link (via /tip/chat/…). Non-tip chat notifications — and all + // contact/phone-addressed chats — no longer have an in-app entry point (the Send + // tab / direct-send flow was removed), so fall through to a plain launch that opens + // the app on the camera instead of firing a now-unhandled /chat/ deeplink. + is NavigationTrigger.Chat.ById -> if (metadata?.chatType == ChatType.TIP_DM) { + Intent(Intent.ACTION_VIEW).apply { + data = Linkify.tipChatById(navigation.chatId).toUri() } - } - - is NavigationTrigger.Chat.ByContact -> Intent(Intent.ACTION_VIEW).apply { - data = Linkify.chatByPhone(navigation.phoneNumber).toUri() + } else { + packageManager.getLaunchIntentForPackage(packageName) } else -> packageManager.getLaunchIntentForPackage(packageName) diff --git a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt index e4e63eb88..1fdd1c878 100644 --- a/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt +++ b/apps/flipcash/shared/router/src/main/kotlin/com/flipcash/app/router/internal/AppRouter.kt @@ -3,7 +3,6 @@ package com.flipcash.app.router.internal import androidx.core.net.toUri import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier -import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.models.chat.ChatId @@ -64,9 +63,6 @@ internal class AppRouter( ) is DeeplinkType.EmailVerification -> resolveEmailVerification(type) - is DeeplinkType.Chat -> DeeplinkAction.Navigate( - listOf(AppRoute.Sheets.Send(), AppRoute.Messaging.Chat(type.identifier)) - ) is DeeplinkType.TipChat -> DeeplinkAction.Navigate( listOf(AppRoute.Sheets.Tips(), AppRoute.Messaging.Chat(type.identifier)) @@ -82,9 +78,12 @@ internal class AppRouter( deepLink.isCashLink() -> deepLink.handleCashLink() deepLink.isToken() -> deepLink.handleTokenLink() deepLink.isEmailVerification() -> deepLink.handleEmailVerification() - deepLink.isChat() -> deepLink.handleChat() deepLink.isTipChat() -> deepLink.handleTipChat() deepLink.isTipCard() -> deepLink.handleTipCard() + // `/chat/{id}` links are intentionally NOT handled: the Send tab / direct-send + // flow they opened was removed, so they fall through to `null` and the app lands + // on the camera. Do not re-add chat routing here without restoring that entry point. + // (Tip DMs use `/tip/chat/{id}` — handled above via isTipChat.) else -> null } } @@ -138,8 +137,6 @@ private fun DeepLink.isToken(): Boolean = token.contains(pathSegments.getOrNull( private fun DeepLink.isEmailVerification(): Boolean = verification.contains(pathSegments.getOrNull(0)) && data.toUri().getQueryParameter("email") != null -private fun DeepLink.isChat(): Boolean = chat.contains(pathSegments.getOrNull(0)) - // https://app.flipcash.com/tip/chat/{url encoded chatId} private fun DeepLink.isTipChat(): Boolean = tip.contains(pathSegments.getOrNull(0)) && chat.contains(pathSegments.getOrNull(1)) @@ -170,24 +167,6 @@ private fun DeepLink.handleTokenLink(): DeeplinkType.TokenInfo? { return DeeplinkType.TokenInfo(Mint(mint)) } -// https://app.flipcash.com/chat/{url encoded chatId} -// https://app.flipcash.com/chat/{url encoded e164} -private fun DeepLink.handleChat(): DeeplinkType.Chat? { - val uri = data.toUri() - // pathSegments already percent-decodes; do NOT urlDecode() again - // because URLDecoder treats '+' as a space, mangling phone numbers. - val chatTarget = uri.pathSegments.getOrNull(1) ?: return null - - val identifier = if (chatTarget.startsWith("+")) { - ChatIdentifier.ByContact(DeviceContact.unknownContact(e164 = chatTarget)) - } else { - val chatId = ChatId(chatTarget.decodeBase64UrlSafe().toList()) - ChatIdentifier.ByChatId(chatId) - } - - return DeeplinkType.Chat(identifier) -} - // https://app.flipcash.com/tip/chat/{url encoded chatId} private fun DeepLink.handleTipChat(): DeeplinkType.TipChat? { val uri = data.toUri() diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt index 47df0c515..ede1a11ff 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/SessionController.kt @@ -67,7 +67,6 @@ data class SessionState( val contactDmUnreadCount: Int = 0, val tipsUnreadCount: Int = 0, val tokens: List = emptyList(), - val isTippingEnabled: Boolean = false, ) val LocalSessionController = staticCompositionLocalOf { null } \ No newline at end of file diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index 6ae7a0532..5af111971 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -273,10 +273,6 @@ class RealSessionController @Inject constructor( stateHolder.update { it.copy(tokens = tokens) } }.launchIn(scope) - featureFlagController.observe(FeatureFlag.Tipping) - .onEach { enabled -> stateHolder.update { it.copy(isTippingEnabled = enabled) } } - .launchIn(scope) - // Retry updateUserFlags when network is restored networkObserver.state .map { it.connected } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt index 1e43937a0..71069303e 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/SessionStateHolder.kt @@ -36,8 +36,8 @@ class SessionStateHolder @Inject constructor() { * Those flag observers are hot [StateFlow]s that only re-emit on a *value change*. * A blanket `SessionState()` reset would clobber these fields to their defaults, and * the observer would not re-push its unchanged current value to repopulate them — - * leaving the UI desynced from the still-persisted flag (e.g. Tipping flag on, but the - * scanner Tips tab gone). Account/token/settings-derived fields are safe to reset: + * leaving the UI desynced from the still-persisted flag (e.g. vibrate-on-scan on, but + * the setting reads as off). Account/token/settings-derived fields are safe to reset: * their sources re-emit when the account changes, so they self-heal. */ fun reset() { @@ -45,7 +45,6 @@ class SessionStateHolder @Inject constructor() { SessionState( vibrateOnScan = prev.vibrateOnScan, showNetworkOffline = prev.showNetworkOffline, - isTippingEnabled = prev.isTippingEnabled, ) } } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt index 594b57d9e..d000e4b8c 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionStateHolderTest.kt @@ -54,12 +54,11 @@ class SessionStateHolderTest { fun `reset preserves device-scoped feature-flag state`() { // These are driven only by observe(flag) StateFlows, which won't re-emit an // unchanged value to repopulate them after a blanket reset — so logout must - // keep them, or the UI desyncs from the still-persisted flag (e.g. Tips tab). + // keep them, or the UI desyncs from the still-persisted flag. val holder = holder() - holder.update { it.copy(isTippingEnabled = true, vibrateOnScan = true, showNetworkOffline = true) } + holder.update { it.copy(vibrateOnScan = true, showNetworkOffline = true) } holder.reset() val state = holder.state.value - assertTrue(state.isTippingEnabled) assertTrue(state.vibrateOnScan) assertTrue(state.showNetworkOffline) } diff --git a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt index 8ad95cad5..d56fcf7ce 100644 --- a/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt +++ b/apps/flipcash/shared/tipping/src/main/kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt @@ -9,8 +9,6 @@ import com.flipcash.app.core.tipping.TipEvent import com.flipcash.app.core.tipping.TipSelectionHolder import com.flipcash.app.core.tipping.TipSelectionState import com.flipcash.app.currency.PreferredCurrencyController -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ProfileController @@ -76,7 +74,6 @@ class TippingCoordinator @Inject constructor( private val purchaseMethodController: PurchaseMethodController, private val analytics: FlipcashAnalyticsService, private val vibrator: Vibrator, - private val featureFlagController: FeatureFlagController, ) : TipSelectionHolder { /** The signed-in user's id ([UserManager.accountId]), or null if unavailable. */ val currentUserId: ID? @@ -286,11 +283,6 @@ class TippingCoordinator @Inject constructor( suspend fun resolveTipCard(userId: ID): Result = resolveProfile(userId) .onSuccess { - // Encountering someone else's tip card — by scan or by tip deeplink, both of which - // land here — opts the viewer into tipping so they can reciprocate without first - // digging the flag out of beta settings themselves. - featureFlagController.set(FeatureFlag.Tipping, true) - // Dual gating, like the send / currency-creator flows: the presentation gate only // asks "is there any giveable balance?" (no amount threshold, so it stays currency- // agnostic). The minimum-tip and per-amount affordability are enforced downstream — diff --git a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt index a30e88a09..693bedd71 100644 --- a/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt +++ b/apps/flipcash/shared/tipping/src/test/kotlin/com/flipcash/shared/tipping/TippingCoordinatorTest.kt @@ -1,8 +1,6 @@ package com.flipcash.shared.tipping import com.flipcash.app.analytics.FlipcashAnalyticsService -import com.flipcash.app.featureflags.FeatureFlag -import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ProfileController @@ -16,7 +14,6 @@ import com.getcode.util.vibration.Vibrator import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import io.mockk.verify import kotlinx.coroutines.test.runTest import kotlin.test.Test import kotlin.test.assertEquals @@ -34,7 +31,6 @@ class TippingCoordinatorTest { private val purchaseMethodController = mockk(relaxed = true) private val analytics = mockk(relaxed = true) private val vibrator = mockk(relaxed = true) - private val featureFlagController = mockk(relaxed = true) private fun buildCoordinator() = TippingCoordinator( profileController, @@ -47,7 +43,6 @@ class TippingCoordinatorTest { purchaseMethodController, analytics, vibrator, - featureFlagController, ) private val coordinator = buildCoordinator() @@ -91,29 +86,6 @@ class TippingCoordinatorTest { assertSame(fetched, result.getOrNull()) } - @Test - fun `resolveTipCard enables the tipping flag on success`() = runTest { - val userId = List(16) { it.toByte() } - coEvery { profileController.getProfileForUser(userId) } returns Result.success(profile("Bob")) - - // The flag is flipped in resolveProfile's onSuccess, before the tip card itself is - // assembled — assembling the card derives an Ed25519 rendezvous key via native crypto - // that isn't available under Robolectric, so guard that downstream step. - runCatching { coordinator.resolveTipCard(userId) } - - verify { featureFlagController.set(FeatureFlag.Tipping, true) } - } - - @Test - fun `resolveTipCard leaves the tipping flag untouched when resolution fails`() = runTest { - val userId = listOf(7, 8, 9) - coEvery { profileController.getProfileForUser(userId) } returns Result.failure(RuntimeException("nope")) - - coordinator.resolveTipCard(userId) - - verify(exactly = 0) { featureFlagController.set(FeatureFlag.Tipping, true) } - } - @Test fun `currentUserId reflects UserManager accountId`() { val id = listOf(9, 9) diff --git a/maestro/README.md b/maestro/README.md index 763874582..37b13d3cc 100644 --- a/maestro/README.md +++ b/maestro/README.md @@ -82,7 +82,7 @@ e.g. `menu_button`, `market_cap_chart`, `chat_message_list`, `send_contact_list` ## Enabling beta flags from a test -Beta-gated features (Tipping, Blocklist, …) can be turned on **at launch** without toggling +Beta-gated features (Blocklist, …) can be turned on **at launch** without toggling them in the Labs UI — mirroring iOS's `--beta-flags`. Pass a `betaFlags` launch argument (a comma-separated list of `FeatureFlag.key`s); `MainActivity` reads it on debug/UI-test builds and force-enables those flags: @@ -91,7 +91,7 @@ and force-enables those flags: - launchApp: arguments: isUiTest: true - betaFlags: "tipping_enabled,blocklist_enabled" + betaFlags: "blocklist_enabled" ``` The overrides must be applied in the **same process** that renders the feature — deeplink @@ -102,7 +102,7 @@ login relaunches via `openLink` and would drop the argument. So use one of: take a `BETA_FLAGS` env var; the runner forwards `BETA_FLAGS` from your shell. ```bash -BETA_FLAGS=tipping_enabled maestro/run.sh maestro/tipping_setup.yaml +maestro/run.sh maestro/tipping_setup.yaml ``` ## Coverage @@ -112,10 +112,9 @@ BETA_FLAGS=tipping_enabled maestro/run.sh maestro/tipping_setup.yaml - `account_navigation.yaml` — menu → My Account → App Settings - `wallet_token_info.yaml` — wallet → token info + market-cap chart - `discovery_leaderboard.yaml` — Discover → leaderboard → token info -- `direct_send.yaml` — send entry → phone gate - `withdraw.yaml` — menu → Withdraw Money → USDC → amount entry (fund-safe) - `deposit.yaml` — menu → Add Money → Other Wallet → USDC deposit (fund-safe) -- `tipping_setup.yaml` — create account (beta flag) → set up tip card → tip card renders +- `tipping_setup.yaml` — create account → set up tip card → tip card renders - `tip_chat.yaml` — open the tip conversation from the Tips tab and send a message - `blocking.yaml` — block a chat participant from their profile, verify in My Account → Blocked, then unblock (leaves the account clean) @@ -133,17 +132,12 @@ and it runs): - `usdf_only_gate.yaml` — reserves-only account: tapping Cash routes to Discover ("No Community Currencies Yet"). Mirrors iOS `GiveDiscoverGateRegressionTests`. Needs `USDF_ONLY_DEEPLINK` (a dedicated USDF-only account, like iOS's `FLIPCASH_UI_TEST_USDF_ONLY_ACCESS_KEY`). -- `send_to_contact.yaml` — send to an on-Flipcash contact (mirrors iOS `SendSmokeTests`, which uses a - fixed contact "Raul Riera"). Parameterized by `CONTACT_NAME`/`CONTACT_PHONE`; **the runner seeds this - contact into the emulator automatically** (idempotent). The only remaining requirement is a - **send-enabled account** — i.e. a phone linked to the account (see below), and `CONTACT_PHONE` mapping - to a real Flipcash user. ### Two phone-verification paths - **Onboarding / account creation** uses the **backend test number** `+15005550000` with OTP `000000` (`create_account.yaml`). This is a backend test hook — no real SMS, no linkable identity. -- **Linking a phone to enable the send flow** — status: **blocked on code delivery.** What's verified: +- **Linking a phone (e.g. for onramp verification)** — status: **blocked on code delivery.** What's verified: - A valid-format number is required (the emulator's own `555-521-5554` is an invalid NPA and is rejected at phone entry). A number like `+1 415-555-0100` is accepted and the code is requested. - The app uses Android's **SMS User Consent** reader: an SMS delivered via @@ -154,10 +148,9 @@ and it runs): to the emulator, so it never arrives and can't be read. - **To unblock:** the dev/staging backend must route the verification SMS for the test number **to this emulator** (e.g. a webhook that calls `adb emu sms send`), so the real code lands in the inbox and the - app reads it. Once that exists, phone-linking is one-time per account and `send_to_contact.yaml` runs - green (contact is auto-seeded by the runner). + app reads it. Once that exists, phone-linking is one-time per account. - **Full Coinbase purchase** — the flow reaches the onramp; completing it needs phone verification - (which links a phone to the shared account and would flip the send flows) plus driving the Google + (which links a phone to the shared account) plus driving the Google Pay sandbox sheet. Note: **iOS doesn't automate the payment either** — its E2E stops at the same onramp/verification boundary (`BuyApplePayRegressionTests`: unverified → verification sheet) and covers order-building/deposit/verification logic with unit tests (`OnrampOrderRequestTests`, diff --git a/maestro/blocking.yaml b/maestro/blocking.yaml index a8979c44a..11bb13ff4 100644 --- a/maestro/blocking.yaml +++ b/maestro/blocking.yaml @@ -3,11 +3,11 @@ name: "Blocking — block & unblock a chat participant" tags: - blocklist --- -# Enable tipping (to reach the tip chat) + blocklist at launch. +# Tipping is on by default (to reach the tip chat); enable blocklist at launch. - runFlow: file: subflows/login_with_flags.yaml env: - BETA_FLAGS: "tipping_enabled,blocklist_enabled" + BETA_FLAGS: "blocklist_enabled" # Open the tip conversation. - tapOn: "Tips" diff --git a/maestro/direct_send.yaml b/maestro/direct_send.yaml deleted file mode 100644 index de7a1abec..000000000 --- a/maestro/direct_send.yaml +++ /dev/null @@ -1,29 +0,0 @@ -appId: com.flipcash.app.android -name: "Direct Send — entry & phone gate" -tags: - - smoke - - payments ---- -# Deterministic clean login → home. -- runFlow: subflows/login_with_deeplink.yaml - -# Open the send flow from the scanner nav bar. -- tapOn: "Send" -- extendedWaitUntil: - visible: - id: send_screen - timeout: 8000 - -# This test account has no phone linked for send, so the flow opens on the phone -# gate (SendStep.PhoneGate). phone_gate_screen is auto-tagged from the step name. -- assertVisible: - id: phone_gate_screen -- assertVisible: "Send Money To Your Friends" - -# Close the sheet and return home. -- tapOn: - id: action_close -- extendedWaitUntil: - visible: - id: scanner_screen - timeout: 8000 diff --git a/maestro/send_to_contact.yaml b/maestro/send_to_contact.yaml deleted file mode 100644 index 863f0a419..000000000 --- a/maestro/send_to_contact.yaml +++ /dev/null @@ -1,28 +0,0 @@ -appId: com.flipcash.app.android -name: "Send to a Flipcash contact" -tags: - - payments ---- -# Send cash to an on-Flipcash contact, then land in the conversation. Mirrors iOS -# SendSmokeTests (which sends to a fixed contact, "Raul Riera"). -# -# Requires (see README "Two phone-verification paths"): -# - a send-enabled account: a phone linked via the emulator's real number + real SMS -# (`adb emu sms send`), not the onboarding test number — else the flow stops at the phone gate; -# - the CONTACT_NAME / CONTACT_PHONE contact (a real Flipcash user) in the emulator's contacts — -# the runner seeds this automatically. -# Until the account is phone-linked this is a scaffold — the steps encode the expected journey. -- runFlow: subflows/login_with_deeplink.yaml - -- tapOn: "Send" -- extendedWaitUntil: { visible: { id: send_screen }, timeout: 8000 } - -# With a linked phone the send flow shows the contact list (not the phone gate). -- extendedWaitUntil: { visible: { id: send_contact_list }, timeout: 8000 } -- tapOn: { id: send_search_field } -- inputText: ${CONTACT_NAME} -- extendedWaitUntil: { visible: { id: send_contact_row }, timeout: 8000 } -- tapOn: { id: send_contact_row, index: 0 } - -# Opening a Flipcash contact lands in the conversation (from which cash/amount is entered). -- extendedWaitUntil: { visible: { id: chat_screen }, timeout: 8000 } diff --git a/maestro/tip_chat.yaml b/maestro/tip_chat.yaml index 75a6ba06f..e67d8a28d 100644 --- a/maestro/tip_chat.yaml +++ b/maestro/tip_chat.yaml @@ -3,12 +3,11 @@ name: "Tip chat — open conversation & send a message" tags: - tipping --- -# Seed-login into the test account (which has tips set up + a tip chat) with the -# tipping beta flag enabled at launch. +# Seed-login into the test account (which has tips set up + a tip chat). - runFlow: file: subflows/login_with_flags.yaml env: - BETA_FLAGS: "tipping_enabled" + BETA_FLAGS: "" # Tips tab lists tip conversations; open the first one. - tapOn: "Tips" diff --git a/maestro/tip_deeplink.yaml b/maestro/tip_deeplink.yaml index 799c4ca5d..0928b148f 100644 --- a/maestro/tip_deeplink.yaml +++ b/maestro/tip_deeplink.yaml @@ -3,14 +3,14 @@ name: "Tip deeplink — opens the tip flow" tags: - tipping --- -# Seed-login with tipping enabled. A fresh login clears local state, so give balances -# a moment to sync (opening the Wallet forces the token balances to load) before opening -# the deeplink — otherwise hasGiveableBalance is transiently false and the app shows the +# Seed-login. A fresh login clears local state, so give balances a moment to sync +# (opening the Wallet forces the token balances to load) before opening the deeplink — +# otherwise hasGiveableBalance is transiently false and the app shows the # add-money/discover gate instead of the tip flow. - runFlow: file: subflows/login_with_flags.yaml env: - BETA_FLAGS: "tipping_enabled" + BETA_FLAGS: "" - runFlow: subflows/navigate_to_wallet.yaml - assertVisible: { id: wallet_screen } diff --git a/maestro/tipping_setup.yaml b/maestro/tipping_setup.yaml index 24fc3d135..2e9152d2a 100644 --- a/maestro/tipping_setup.yaml +++ b/maestro/tipping_setup.yaml @@ -4,14 +4,13 @@ tags: - tipping - creates-account --- -# Tip setup is one-run-per-account, so start from a brand-new account with the tipping -# beta flag enabled at launch (no Labs-UI toggling). +# Tip setup is one-run-per-account, so start from a brand-new account. - runFlow: file: subflows/create_account.yaml env: - BETA_FLAGS: "tipping_enabled" + BETA_FLAGS: "" -# Tipping is enabled -> the Tips tab is present. +# Tipping is on by default -> the Tips tab is present. - assertVisible: "Tips" - tapOn: "Tips" - extendedWaitUntil: { visible: { id: tips_screen }, timeout: 8000 } diff --git a/settings.gradle.kts b/settings.gradle.kts index a6d63b0d6..f47d6f7fb 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -103,7 +103,6 @@ include( ":apps:flipcash:features:deposit", ":apps:flipcash:features:advanced", ":apps:flipcash:features:currency-creator", - ":apps:flipcash:features:direct-send", ":apps:flipcash:features:messenger", ":apps:flipcash:features:invite", ":apps:flipcash:features:device-logs",