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 90151d7cf3..fbf059c749 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 @@ -12,7 +12,7 @@ import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.android.BuildConfig import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.appsettings.LocalAppSettings @@ -49,7 +49,7 @@ import com.flipcash.app.updates.AppUpdateController import com.flipcash.app.updates.LocalAppUpdater import com.flipcash.services.user.UserManager import com.flipcash.shared.tipping.TippingCoordinator -import com.getcode.libs.analytics.LocalAnalytics +import com.flipcash.app.analytics.LocalAnalytics import com.getcode.opencode.compose.LocalExchange import com.getcode.opencode.exchange.Exchange import com.getcode.ui.testing.LocalUiTesting @@ -118,7 +118,7 @@ class MainActivity : FragmentActivity() { lateinit var featureFlagController: FeatureFlagController @Inject - lateinit var analytics: FlipcashAnalyticsService + lateinit var analytics: FlipcashAnalytics @Inject lateinit var phoneUtils: PhoneUtils @@ -241,4 +241,4 @@ private fun Activity.handleUncaughtException() { killProcess(myPid()) exitProcess(2) } -} \ No newline at end of file +} diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt index 2fe637eb96..a8f8efdbbc 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/App.kt @@ -37,7 +37,9 @@ import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavKey import androidx.navigation3.scene.OverlayScene import androidx.navigation3.scene.SinglePaneSceneStrategy +import com.flipcash.analytics.events.DeeplinkEvents import com.flipcash.app.analytics.rememberAnalytics +import com.flipcash.app.analytics.withoutQueryOrFragment import com.flipcash.app.android.BuildConfig import com.flipcash.app.bill.customization.BillPlaygroundScaffold import com.flipcash.app.cardexpand.CardExpansionController @@ -117,7 +119,7 @@ internal fun App( var deeplinkHandled by remember { mutableStateOf(false) } val userManager = LocalUserManager.current!! DeepLinkListener { - analytics.deeplinkOpened(it.data) + analytics.track(DeeplinkEvents.open(it.data.withoutQueryOrFragment())) deepLink = it } diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt index 5329c1d339..f2c0563fb3 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/decorators/NavMessagingEntryDecorator.kt @@ -6,6 +6,7 @@ import androidx.compose.runtime.remember import androidx.navigation3.runtime.NavBackStack import androidx.navigation3.runtime.NavEntryDecorator import androidx.navigation3.runtime.NavKey +import com.flipcash.analytics.events.ErrorModalEvents import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.getcode.manager.BottomBarManager @@ -46,11 +47,13 @@ fun NavMessagingEntryDecorator( BottomBarManager.BottomBarMessageType.DESTRUCTIVE -> Unit BottomBarManager.BottomBarMessageType.ERROR -> { val screen = backStack.lastOrNull()?.screenName() - analytics.displayedErrorModal( - title = message.title, - message = message.subtitle, - screen = screen, - callSite = message.callSite, + analytics.track( + ErrorModalEvents.displayed( + title = message.title, + message = message.subtitle, + screen = screen, + callSite = message.callSite, + ) ) } BottomBarManager.BottomBarMessageType.WARNING -> Unit diff --git a/apps/flipcash/features/balance/build.gradle.kts b/apps/flipcash/features/balance/build.gradle.kts index ca9898ebd0..35b9361647 100644 --- a/apps/flipcash/features/balance/build.gradle.kts +++ b/apps/flipcash/features/balance/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(project(":apps:flipcash:shared:transaction-history")) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:funding")) diff --git a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt index 3aaa47a73f..8d4fbfa61c 100644 --- a/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt +++ b/apps/flipcash/features/balance/src/main/kotlin/com/flipcash/app/balance/internal/WalletViewModel.kt @@ -1,8 +1,9 @@ package com.flipcash.app.balance.internal import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.app.core.AppRoute import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator @@ -35,7 +36,7 @@ internal class WalletViewModel @Inject constructor( userFlags: UserFlagsCoordinator, dispatchers: DispatcherProvider, purchaseMethodController: PurchaseMethodController, - analytics: FlipcashAnalyticsService, + analytics: FlipcashAnalytics, chatCoordinator: ChatCoordinator, feedCoordinator: ActivityFeedCoordinator, tokenCoordinator: TokenCoordinator, @@ -176,7 +177,7 @@ internal class WalletViewModel @Inject constructor( eventFlow .filterIsInstance() .mapNotNull { - analytics.addMoneyOpened(Analytics.AddMoneySource.Balance) + analytics.track(AddMoneyEvents.opened(AddMoneySource.BALANCE)) purchaseMethodController.presentDepositOptions(popToRoot = true) } .onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) @@ -243,4 +244,4 @@ internal class WalletViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt index 26542feeac..a5c31598b6 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/BalanceViewModelTest.kt @@ -1,7 +1,9 @@ package com.flipcash.app.balance.internal import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.flipcash.app.analytics.StubFlipcashAnalytics +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers @@ -55,6 +57,8 @@ class BalanceViewModelTest { every { pending } returns MutableStateFlow(null) } + private val analytics = RecordingAnalytics() + private lateinit var dispatchers: TestDispatchers private fun createViewModel() = WalletViewModel( @@ -62,7 +66,7 @@ class BalanceViewModelTest { userFlags = userFlags, dispatchers = dispatchers, purchaseMethodController = purchaseMethodController, - analytics = StubFlipcashAnalytics(), + analytics = analytics, chatCoordinator = chatCoordinator, feedCoordinator = feedCoordinator, tokenCoordinator = tokenCoordinator, @@ -83,6 +87,7 @@ class BalanceViewModelTest { // The removed AddMoneyUX flag used to short-circuit to a plain Deposit route; // the add-money sheet must now always be presented. coVerify(exactly = 1) { purchaseMethodController.presentDepositOptions(popToRoot = true) } + assertEquals(listOf(AddMoneyEvents.opened(AddMoneySource.BALANCE)), analytics.events) } @Test diff --git a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt index d02fa555ae..384eb86e40 100644 --- a/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt +++ b/apps/flipcash/features/balance/src/test/kotlin/com/flipcash/app/balance/internal/WalletMilestoneGatingTest.kt @@ -1,7 +1,7 @@ package com.flipcash.app.balance.internal import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.flipcash.app.analytics.StubFlipcashAnalytics +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers @@ -67,7 +67,7 @@ class WalletMilestoneGatingTest { userFlags = mockk(relaxed = true), dispatchers = dispatchers, purchaseMethodController = mockk(relaxed = true), - analytics = StubFlipcashAnalytics(), + analytics = FlipcashAnalytics.None, chatCoordinator = chatCoordinator, feedCoordinator = feedCoordinator, tokenCoordinator = tokenCoordinator, diff --git a/apps/flipcash/features/cash/build.gradle.kts b/apps/flipcash/features/cash/build.gradle.kts index 06c25c654d..750d1d06ee 100644 --- a/apps/flipcash/features/cash/build.gradle.kts +++ b/apps/flipcash/features/cash/build.gradle.kts @@ -16,6 +16,7 @@ dependencies { implementation(libs.kotlin.stdlib) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:session")) implementation(project(":apps:flipcash:shared:tokens")) diff --git a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt index cffeaf377a..e695ef3ee9 100644 --- a/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt +++ b/apps/flipcash/features/cash/src/main/kotlin/com/flipcash/app/cash/internal/CashScreenViewModel.kt @@ -1,8 +1,9 @@ package com.flipcash.app.cash.internal import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.tokens.SwapPurpose @@ -58,7 +59,7 @@ internal class CashScreenViewModel @Inject constructor( private val verifiedFiatCalculator: VerifiedFiatCalculator, tokenCoordinator: TokenCoordinator, transactionController: TransactionOperations, - analytics: FlipcashAnalyticsService, + analytics: FlipcashAnalytics, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -289,7 +290,7 @@ internal class CashScreenViewModel @Inject constructor( // route directly to the swap amount screen, skipping token info val mint = stateFlow.value.selectedTokenAddress!! if (mint == Mint.usdf) { - analytics.addMoneyOpened(Analytics.AddMoneySource.GiveShortfall) + analytics.track(AddMoneyEvents.opened(AddMoneySource.GIVE_SHORTFALL)) } dispatchEvent( Event.OpenScreen( @@ -340,4 +341,4 @@ internal class CashScreenViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt index ae4e368d1e..35f716ade4 100644 --- a/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt +++ b/apps/flipcash/features/cash/src/test/kotlin/com/flipcash/app/cash/internal/CashScreenViewModelTest.kt @@ -1,7 +1,9 @@ package com.flipcash.app.cash.internal import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.flipcash.app.analytics.StubFlipcashAnalytics +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.tokens.TokenCoordinator @@ -51,6 +53,8 @@ class CashScreenViewModelTest { private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) private val transactionController: TransactionOperations = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + private lateinit var dispatchers: TestDispatchers private val updateStateForEvent = CashScreenViewModel.updateStateForEvent @@ -79,7 +83,7 @@ class CashScreenViewModelTest { verifiedFiatCalculator = verifiedFiatCalculator, tokenCoordinator = tokenCoordinator, transactionController = transactionController, - analytics = StubFlipcashAnalytics(), + analytics = analytics, dispatchers = dispatchers, ) } @@ -266,4 +270,26 @@ class CashScreenViewModelTest { val result = vm.checkSendLimit() assertFalse(result, "checkSendLimit should return false when amount is within limit") } + + @Test + fun `a USDF shortfall tracks Add Money Opened from Give Shortfall`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.dispatchEvent(CashScreenViewModel.Event.OnTokenSelected(Mint.usdf)) + vm.dispatchEvent(CashScreenViewModel.Event.AddCashToWallet(Fiat(5.0))) + advanceUntilIdle() + + assertEquals(listOf(AddMoneyEvents.opened(AddMoneySource.GIVE_SHORTFALL)), analytics.events) + } + + @Test + fun `a shortfall in another token tracks nothing`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + val vm = createViewModel() + vm.dispatchEvent(CashScreenViewModel.Event.OnTokenSelected(Mint("kinXdEcpDQeHPEuQnqmUgtYykqKGVFq6CeVX5iAHJq6"))) + vm.dispatchEvent(CashScreenViewModel.Event.AddCashToWallet(Fiat(5.0))) + advanceUntilIdle() + + assertTrue(analytics.events.isEmpty()) + } } diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailMagicLinkScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailMagicLinkScreen.kt index 0ee0c5e21a..842247f513 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailMagicLinkScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailMagicLinkScreen.kt @@ -8,7 +8,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.OnrampStep +import com.flipcash.analytics.events.OnrampEvents import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.contact.verification.internal.email.EmailMagicLinkScreen import com.flipcash.app.contact.verification.internal.email.EmailVerificationViewModel @@ -43,7 +44,7 @@ fun EmailMagicLinkContent( val analytics = rememberAnalytics() LaunchedEffect(Unit) { - analytics.onrampVerification(Analytics.OnrampVerificationStep.ConfirmEmail) + analytics.track(OnrampEvents.step(OnrampStep.CONFIRM_EMAIL)) } Column( diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt index 7279f7e5f7..3417658dce 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/email/EmailVerificationScreen.kt @@ -8,7 +8,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.OnrampStep +import com.flipcash.analytics.events.OnrampEvents import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.contact.verification.internal.email.EmailEntryScreen import com.flipcash.app.contact.verification.internal.email.EmailVerificationViewModel @@ -56,7 +57,7 @@ fun EmailVerificationContent( val analytics = rememberAnalytics() LaunchedEffect(Unit) { - analytics.onrampVerification(Analytics.OnrampVerificationStep.EnterEmail) + analytics.track(OnrampEvents.step(OnrampStep.ENTER_EMAIL)) } BackHandler { diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationIntroScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationIntroScreen.kt index b08555e29a..44e749ff8b 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationIntroScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/VerificationIntroScreen.kt @@ -24,7 +24,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.OnrampStep +import com.flipcash.analytics.events.OnrampEvents import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.verification.VerificationResult import com.flipcash.app.core.verification.VerificationStep @@ -55,7 +56,7 @@ fun VerificationIntroScreen( val analytics = rememberAnalytics() LaunchedEffect(Unit) { - analytics.onrampVerification(Analytics.OnrampVerificationStep.ShowInfo) + analytics.track(OnrampEvents.step(OnrampStep.SHOW_INFO)) } } diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt index 13d86ef372..b094209478 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt @@ -3,8 +3,8 @@ package com.flipcash.app.contact.verification.internal.phone import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Action -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.events.AccountEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.extensions.onResult import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.phone.CountryLocale @@ -52,7 +52,7 @@ internal class PhoneVerificationViewModel @Inject constructor( private val featureFlags: FeatureFlagController, private val resources: ResourceHelper, private val dispatchers: DispatcherProvider, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -189,7 +189,7 @@ internal class PhoneVerificationViewModel @Inject constructor( viewModelScope.launch { delay(1.seconds) - analytics.action(Action.VerifiedPhoneNumber) + analytics.track(AccountEvents.verifiedPhoneNumber()) dispatchEvent(Event.OnCodeVerified) dispatchEvent(Event.OnVerifyingCodeChanged()) } @@ -239,7 +239,7 @@ internal class PhoneVerificationViewModel @Inject constructor( .onResult( onSuccess = { trace(tag = traceTag, message = "linkForPayment succeeded", type = TraceType.Process) - analytics.action(Action.LinkedPhoneNumber) + analytics.track(AccountEvents.linkedPhoneNumber()) dispatchEvent(Event.OnPhoneVerificationComplete) }, onError = { @@ -315,7 +315,7 @@ internal class PhoneVerificationViewModel @Inject constructor( } if (!isResend) { - analytics.action(Action.EnteredPhoneNumber) + analytics.track(AccountEvents.enteredPhoneNumber()) } trace(tag = traceTag, message = if (isResend) "Resending verification code" else "Sending verification code", type = TraceType.Process) @@ -432,4 +432,4 @@ internal class PhoneVerificationViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt index a056d373d8..22f3bf99dc 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt @@ -7,7 +7,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.OnrampStep +import com.flipcash.analytics.events.OnrampEvents import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.contact.verification.internal.phone.PhoneCodeScreen import com.flipcash.app.contact.verification.internal.phone.PhoneVerificationViewModel @@ -43,7 +44,7 @@ fun PhoneCodeContent( val analytics = rememberAnalytics() LaunchedEffect(Unit) { - analytics.onrampVerification(Analytics.OnrampVerificationStep.ConfirmPhone) + analytics.track(OnrampEvents.step(OnrampStep.CONFIRM_PHONE)) } LaunchedEffect(viewModel) { diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt index f0c7b9698f..37a8a0b3ef 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt @@ -8,7 +8,8 @@ import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.OnrampStep +import com.flipcash.analytics.events.OnrampEvents import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.contact.verification.internal.phone.PhoneEntryScreen import com.flipcash.app.contact.verification.internal.phone.PhoneVerificationViewModel @@ -49,7 +50,7 @@ fun PhoneVerificationContent() { val analytics = rememberAnalytics() LaunchedEffect(Unit) { - analytics.onrampVerification(Analytics.OnrampVerificationStep.EnterPhone) + analytics.track(OnrampEvents.step(OnrampStep.ENTER_PHONE)) } BackHandler { diff --git a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/OnrampStepEventTest.kt b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/OnrampStepEventTest.kt new file mode 100644 index 0000000000..4680146419 --- /dev/null +++ b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/OnrampStepEventTest.kt @@ -0,0 +1,35 @@ +package com.flipcash.app.contact.verification + +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.OnrampStep +import com.flipcash.analytics.events.OnrampEvents +import org.junit.Test +import kotlin.test.assertEquals + +/** + * The five verification screens track [OnrampEvents.step] straight from a `LaunchedEffect`. These + * pin each step to the event name those screens sent before the shared builders, with no + * properties, so the move changes nothing Mixpanel receives. + */ +class OnrampStepEventTest { + + @Test + fun `each verification step keeps its event name`() { + assertEquals( + listOf( + AnalyticsEvent("Onramp: Show Verification Info"), + AnalyticsEvent("Onramp: Show Enter Phone"), + AnalyticsEvent("Onramp: Show Confirm Phone"), + AnalyticsEvent("Onramp: Show Enter Email"), + AnalyticsEvent("Onramp: Show Confirm Email"), + ), + listOf( + OnrampStep.SHOW_INFO, + OnrampStep.ENTER_PHONE, + OnrampStep.CONFIRM_PHONE, + OnrampStep.ENTER_EMAIL, + OnrampStep.CONFIRM_EMAIL, + ).map(OnrampEvents::step), + ) + } +} diff --git a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt index f30eed5a66..3d8f83331c 100644 --- a/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt +++ b/apps/flipcash/features/deposit/src/main/kotlin/com/flipcash/app/deposit/internal/DepositViewModel.kt @@ -2,7 +2,8 @@ package com.flipcash.app.deposit.internal import android.content.ClipboardManager import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.setText import com.flipcash.features.deposit.R @@ -35,7 +36,7 @@ internal class DepositViewModel @Inject constructor( resources: ResourceHelper, dispatchers: DispatcherProvider, featureFlags: FeatureFlagController, - analytics: FlipcashAnalyticsService, + analytics: FlipcashAnalytics, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -109,7 +110,7 @@ internal class DepositViewModel @Inject constructor( label = resources.getString(R.string.title_clipboardLabelDepositAddress), ) stateFlow.value.selectedTokenAddress?.let { mint -> - analytics.addMoneyAddressCopied(mint) + analytics.track(AddMoneyEvents.addressCopied(mint.base58())) } } .onEach { @@ -136,4 +137,4 @@ internal class DepositViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/login/build.gradle.kts b/apps/flipcash/features/login/build.gradle.kts index 5316fb5af4..8b4a065713 100644 --- a/apps/flipcash/features/login/build.gradle.kts +++ b/apps/flipcash/features/login/build.gradle.kts @@ -15,6 +15,7 @@ dependencies { implementation(project(":apps:flipcash:shared:accesskey")) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:authentication")) implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt index 95b88ea4eb..96d2515c40 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/OnboardingFlowScreen.kt @@ -22,8 +22,10 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider -import com.flipcash.app.analytics.Action -import com.flipcash.app.analytics.Button +import com.flipcash.analytics.Button +import com.flipcash.analytics.events.AccountEvents +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.userprofile.UpdateProfileStep @@ -55,7 +57,6 @@ import com.flipcash.app.purchase.internal.PurchaseAccountViewModel import com.flipcash.features.login.R import com.flipcash.services.user.AuthState import com.flipcash.services.user.UserManager -import com.getcode.libs.analytics.LocalAnalytics import com.getcode.utils.TraceType import com.getcode.utils.trace import com.getcode.navigation.annotatedEntry @@ -156,7 +157,7 @@ private fun PermissionsPhaseFlowHost( val checker = LocalPermissionChecker.current val contactConfig = PermissionConfigs.contacts() val notificationConfig = PermissionConfigs.notifications() - val analytics = LocalAnalytics.current + val analytics = rememberAnalytics() val featureFlags = LocalFeatureFlags.current val userManager = LocalUserManager.current @@ -193,7 +194,7 @@ private fun PermissionsPhaseFlowHost( onExit = { reason, _ -> when (reason) { is FlowExitReason.Completed -> { - analytics.action(Action.CompletedOnboarding) + analytics.track(AccountEvents.completeOnboarding()) trace(tag = "Onboarding", message = "Onboarding complete — releasing to $home", type = TraceType.Process) userManager?.set(AuthState.Ready) outerNavigator.navigate( @@ -571,14 +572,14 @@ private fun PurchaseStepContent() { @Composable private fun ContactPermissionStepContent() { val flowNavigator = rememberFlowNavigator() - val analytics = LocalAnalytics.current + val analytics = rememberAnalytics() val contactCoordinator = LocalContactCoordinator.current val scope = rememberCoroutineScope() val permissionState = rememberContactPermission { result -> when (result) { PermissionResult.Granted -> { - analytics.action(Button.AllowContacts) + analytics.track(ButtonEvents.tapped(Button.ALLOW_CONTACTS)) scope.launch { contactCoordinator.sync() } flowNavigator.proceed() } @@ -591,7 +592,7 @@ private fun ContactPermissionStepContent() { ContactScreenContent( accessHandle = permissionState.asContactAccessHandle(), onSkip = { - analytics.action(Button.SkipContacts) + analytics.track(ButtonEvents.tapped(Button.SKIP_CONTACTS)) flowNavigator.proceed() }, ) @@ -602,7 +603,7 @@ private fun ContactPermissionStepContent() { @Composable private fun NotificationPermissionStepContent() { val flowNavigator = rememberFlowNavigator() - val analytics = LocalAnalytics.current + val analytics = rememberAnalytics() val checker = LocalPermissionChecker.current val notificationConfig = PermissionConfigs.notifications() @@ -617,7 +618,7 @@ private fun NotificationPermissionStepContent() { val permissionState = rememberNotificationPermission { result -> when (result) { PermissionResult.Granted -> { - analytics.action(Button.AllowPush) + analytics.track(ButtonEvents.tapped(Button.ALLOW_PUSH)) flowNavigator.proceed() } PermissionResult.Denied -> flowNavigator.navigateTo( @@ -633,7 +634,7 @@ private fun NotificationPermissionStepContent() { NotificationScreenContent( permissionState = permissionState, onSkip = { - analytics.action(Button.SkipPush) + analytics.track(ButtonEvents.tapped(Button.SKIP_PUSH)) flowNavigator.proceed() }, ) diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModel.kt index ee9967df91..8da7981d8e 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModel.kt @@ -1,8 +1,9 @@ package com.flipcash.app.login.internal +import com.flipcash.analytics.Button +import com.flipcash.analytics.events.ButtonEvents import com.flipcash.app.accesskey.BaseAccessKeyViewModel -import com.flipcash.app.analytics.Button -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.storage.MediaSaver @@ -32,11 +33,11 @@ internal class LoginAccessKeyViewModel @Inject constructor( private val userFlags: UserFlagsCoordinator, private val featureFlags: FeatureFlagController, private val authManager: AuthManager, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, ): BaseAccessKeyViewModel(resources, mnemonicManager, mediaSaver, userManager, qrCodeGenerator, dispatchers) { suspend fun onWroteDownInstead(): Result { - trackButton(Button.WroteAccessKey) + trackButton(Button.WROTE_ACCESS_KEY) uiFlow.update { it.copy(skipState = LoadingSuccessState(loading = true)) } return runCatching { authManager.onUserAccessKeySeen() @@ -59,7 +60,7 @@ internal class LoginAccessKeyViewModel @Inject constructor( } suspend fun saveImage(): Result { - trackButton(Button.SaveAccessKey) + trackButton(Button.SAVE_ACCESS_KEY) return saveBitmapToFile() .onSuccess { authManager.onUserAccessKeySeen() } .mapCatching { @@ -86,7 +87,7 @@ internal class LoginAccessKeyViewModel @Inject constructor( } private fun trackButton(button: Button): Result { - analytics.buttonTapped(button) + analytics.track(ButtonEvents.tapped(button)) return Result.success(Unit) } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt index 46f1111c11..4242fc2530 100644 --- a/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt +++ b/apps/flipcash/features/login/src/main/kotlin/com/flipcash/app/login/router/LoginViewModel.kt @@ -1,8 +1,9 @@ package com.flipcash.app.login.router import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Button -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.Button +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.auth.AuthManager import com.flipcash.features.login.R import com.flipcash.services.controllers.AccountController @@ -34,7 +35,7 @@ class LoginViewModel @Inject constructor( private val authManager: AuthManager, private val accounts: AccountController, private val resources: ResourceHelper, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), @@ -91,7 +92,7 @@ class LoginViewModel @Inject constructor( eventFlow .filterIsInstance() .filter { createInFlight.compareAndSet(false, true) } - .onEach { analytics.buttonTapped(Button.CreateAccount) } + .onEach { analytics.track(ButtonEvents.tapped(Button.CREATE_ACCOUNT)) } .onEach { try { authManager.createAccount() @@ -240,4 +241,4 @@ class LoginViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModelTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModelTest.kt index 9bd5872d3a..513ca8663f 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModelTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/internal/LoginAccessKeyViewModelTest.kt @@ -1,6 +1,8 @@ package com.flipcash.app.login.internal -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.Button +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.auth.AuthManager import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.dispatchers.TestDispatchers @@ -25,6 +27,7 @@ import org.mockito.kotlin.mock import org.mockito.kotlin.never import org.mockito.kotlin.verifyBlocking import org.mockito.kotlin.whenever +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -52,7 +55,7 @@ class LoginAccessKeyViewModelTest { private val userManager: UserManager = mockk(relaxed = true) private val userFlags: UserFlagsCoordinator = mockk(relaxed = true) private val featureFlags: FeatureFlagController = mockk(relaxed = true) - private val analytics: FlipcashAnalyticsService = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private lateinit var dispatchers: TestDispatchers @@ -115,4 +118,26 @@ class LoginAccessKeyViewModelTest { assertTrue(result.getOrThrow()) // requiresIap == true verifyBlocking(authManager, never()) { ensureCoreAccountProvisioned() } } + + @Test + fun `onWroteDownInstead tracks the Wrote Access Key button`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { userFlags.resolvedFlags.value.requiresIapForRegistration.effectiveValue } returns true + val viewModel = createViewModel() + + viewModel.onWroteDownInstead() + + assertEquals(ButtonEvents.tapped(Button.WROTE_ACCESS_KEY), analytics.events.single()) + } + + @Test + fun `saveImage tracks the Save Access Key button`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { userFlags.resolvedFlags.value.requiresIapForRegistration.effectiveValue } returns true + val viewModel = createViewModel() + + viewModel.saveImage() + + assertEquals(ButtonEvents.tapped(Button.SAVE_ACCESS_KEY), analytics.events.single()) + } } diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt index 842fc4589b..4460ca1a51 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelCreateAccountTest.kt @@ -1,7 +1,9 @@ package com.flipcash.app.login.router import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.Button +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.auth.AuthManager import com.flipcash.app.auth.internal.accounts.AccountStore import com.flipcash.app.core.MainCoroutineRule @@ -25,6 +27,7 @@ import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import kotlin.test.assertEquals import kotlin.test.assertFalse import kotlin.test.assertTrue @@ -46,7 +49,7 @@ class LoginViewModelCreateAccountTest { private val accounts: AccountController = mock() private val accountStore: AccountStore = mockk(relaxed = true) private val resources = FakeResourceHelper() - private val analytics: FlipcashAnalyticsService = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private lateinit var dispatchers: TestDispatchers @@ -84,6 +87,11 @@ class LoginViewModelCreateAccountTest { // so loading is set before the async createAccount() call is dispatched. assertTrue(viewModel.stateFlow.value.creatingAccount.loading) + + // The tap is tracked off the eventFlow pipeline, which runs on `dispatchers.Default` + // rather than inline with dispatchEvent, so it needs a pump to have landed. + runCurrent() + assertEquals(ButtonEvents.tapped(Button.CREATE_ACCOUNT), analytics.events.single()) } @Test diff --git a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt index bc38c49a22..9af3cb41df 100644 --- a/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt +++ b/apps/flipcash/features/login/src/test/kotlin/com/flipcash/app/login/router/LoginViewModelErrorTest.kt @@ -1,7 +1,9 @@ package com.flipcash.app.login.router import androidx.arch.core.executor.testing.InstantTaskExecutorRule -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.Button +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.auth.AuthManager import com.flipcash.app.auth.internal.accounts.AccountStore import com.flipcash.app.core.MainCoroutineRule @@ -23,6 +25,7 @@ import org.junit.Rule import org.junit.Test import org.mockito.kotlin.mock import org.mockito.kotlin.whenever +import kotlin.test.assertEquals import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -41,7 +44,7 @@ class LoginViewModelErrorTest { // MockK for everything else private val resources = FakeResourceHelper() - private val analytics: FlipcashAnalyticsService = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private lateinit var dispatchers: TestDispatchers @@ -81,6 +84,7 @@ class LoginViewModelErrorTest { advanceUntilIdle() assertTrue(BottomBarManager.messages.value.any { it.title == "error_title_createAccountFailed" }) + assertEquals(ButtonEvents.tapped(Button.CREATE_ACCOUNT), analytics.events.single()) } @Test diff --git a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt index d8624dbd55..854a426b99 100644 --- a/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt +++ b/apps/flipcash/features/menu/src/main/kotlin/com/flipcash/app/menu/internal/MenuScreenViewModel.kt @@ -2,8 +2,9 @@ package com.flipcash.app.menu.internal import android.content.ClipboardManager import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.bills.share.TipCodePreviewCache import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.VersionInfo @@ -79,7 +80,7 @@ internal class MenuScreenViewModel @Inject constructor( releaseStageProvider: ReleaseStageProvider, purchaseMethodController: PurchaseMethodController, totalBalance: TotalBalanceProvider, - analytics: FlipcashAnalyticsService, + analytics: FlipcashAnalytics, private val tippingCoordinator: TippingCoordinator, private val tipCodePreviewCache: TipCodePreviewCache, private val shareable: ShareSheetController, @@ -156,7 +157,7 @@ internal class MenuScreenViewModel @Inject constructor( * deliberate visit to Add Money. */ data class PresentDepositOptions( - val source: Analytics.AddMoneySource = Analytics.AddMoneySource.Menu, + val source: AddMoneySource = AddMoneySource.MENU, ) : Event data class OpenScreen(val screen: AppRoute) : Event data class OnTipCardStateChanged(val tipCardState: TipCardState) : Event @@ -246,7 +247,7 @@ internal class MenuScreenViewModel @Inject constructor( eventFlow .filterIsInstance() .mapNotNull { event -> - analytics.addMoneyOpened(event.source) + analytics.track(AddMoneyEvents.opened(event.source)) purchaseMethodController.presentDepositOptions(popToRoot = true) }.onEach { route -> dispatchEvent(Event.OpenScreen(route)) } .launchIn(viewModelScope) @@ -350,7 +351,7 @@ internal class MenuScreenViewModel @Inject constructor( onClick = { dispatchEvent( Event.PresentDepositOptions( - Analytics.AddMoneySource.UsernameShortfall + AddMoneySource.USERNAME_SHORTFALL ) ) }, @@ -596,4 +597,4 @@ internal class MenuScreenViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 459b903778..4c59fcef43 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -8,6 +8,8 @@ android { dependencies { implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) + testImplementation(testFixtures(project(":libs:coroutines"))) implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:chat")) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index 28c91eb6aa..651e14c8a8 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -13,8 +13,13 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.analytics.events.ChatEvents +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute import com.flipcash.app.core.chat.ChatIdentifier @@ -27,6 +32,7 @@ import com.flipcash.app.core.ui.ConfirmationStyle import com.flipcash.app.core.util.Linkify import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.messenger.internal.link.CashCardTap +import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.shared.chat.UnreadBoundary import com.flipcash.app.messenger.internal.link.ClaimReplyTargets import com.flipcash.app.messenger.internal.link.LinkCardClassifier @@ -158,16 +164,18 @@ internal class ChatViewModel @Inject constructor( private val purchaseMethodController: PurchaseMethodController, private val userManager: UserManager, private val resources: ResourceHelper, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val clipboardManager: ClipboardManager, private val userFlags: UserFlagsCoordinator, private val linkCardClassifier: LinkCardClassifier, private val linkCardResolver: LinkCardResolver, private val cashLinkClaims: CashLinkClaims, private val chatDraftStore: ChatDraftStore, + dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, ) { sealed interface ResolveState { @@ -1838,11 +1846,11 @@ internal class ChatViewModel @Inject constructor( chatCoordinator.sendMessage(chatId, textToSend, replyToMessageId) .onSuccess { trace("message sent successfully") - analytics.messageSentInChat(type = chatType) + analytics.track(ChatEvents.sentMessage(chatType.analytics, null)) } .onFailure { cause -> trace("message failed to send - ${cause.localizedMessage}") - analytics.messageSentInChat(type = chatType, error = cause) + analytics.track(ChatEvents.sentMessage(chatType.analytics, cause.analytics)) } } } @@ -1911,7 +1919,7 @@ internal class ChatViewModel @Inject constructor( eventFlow .filterIsInstance() .onEach { - analytics.addMoneyOpened(Analytics.AddMoneySource.Chat) + analytics.track(AddMoneyEvents.opened(AddMoneySource.CHAT)) purchaseMethodController.presentDepositOptions()?.let { route -> dispatchEvent(Event.OpenScreen(route)) } @@ -2017,16 +2025,17 @@ internal class ChatViewModel @Inject constructor( // other send from this screen (contact DM or unlocked tip DM) is a plain cash // send. Same `tipAction` the wire `action` above was set from, not a second // "is this a tip" check that could drift from it. - val transferEvent = - if (tipAction == TipAction.TIP) Analytics.Transfer.SentTip else Analytics.Transfer.SentCash + val isTip = tipAction == TipAction.TIP result.onSuccess { dispatchEvent(Event.SendStateUpdated(success = true)) delay(400.milliseconds) - analytics.transfer( - event = transferEvent, - amount = verifiedFiat.localFiat, - successful = true, + analytics.track( + if (isTip) { + TransferEvents.sentTip(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null) + } else { + TransferEvents.sentCash(AnalyticsState.SUCCESS, verifiedFiat.localFiat.analytics, null) + } ) dispatchEvent( Dispatchers.Main, @@ -2034,10 +2043,12 @@ internal class ChatViewModel @Inject constructor( ) }.onFailure { cause -> dispatchEvent(Event.SendStateUpdated()) - analytics.transfer( - event = transferEvent, - amount = verifiedFiat.localFiat, - error = cause, + analytics.track( + if (isTip) { + TransferEvents.sentTip(AnalyticsState.FAILURE, verifiedFiat.localFiat.analytics, cause.analytics) + } else { + TransferEvents.sentCash(AnalyticsState.FAILURE, verifiedFiat.localFiat.analytics, cause.analytics) + } ) BottomBarManager.showError( title = resources.getString(R.string.error_title_cashFailedToSend), diff --git a/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt new file mode 100644 index 0000000000..8c63e3cace --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -0,0 +1,183 @@ +package com.flipcash.app.messenger.internal + +import android.content.ClipboardManager +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import com.flipcash.analytics.PropertyValue +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.chat.ChatParticipant +import com.flipcash.app.core.contacts.DeviceContact +import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.app.messenger.internal.link.LinkCardClassifier +import com.flipcash.app.messenger.internal.link.LinkCardResolver +import com.flipcash.app.session.CashLinkClaims +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.app.userflags.UserFlagsCoordinator +import com.flipcash.libs.coroutines.TestDispatcherProvider +import com.flipcash.services.models.UserProfile +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.ChatCoordinator +import com.flipcash.shared.chat.ChatDraftStore +import com.flipcash.shared.payments.ContactPaymentDelegate +import com.flipcash.shared.payments.TipPaymentDelegate +import com.getcode.manager.BottomBarManager +import com.getcode.opencode.controllers.TransactionController +import com.getcode.opencode.exchange.Exchange +import com.getcode.opencode.exchange.VerifiedFiat +import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.core.bytes +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.opencode.model.financial.Token +import com.getcode.solana.keys.PublicKey +import com.getcode.util.resources.ResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Rule +import org.junit.Test +import java.util.UUID +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * A failed chat send used to report `State: Success` — the delegate failed, but the analytics + * event said the payment went through. This pins the fixed behaviour: a failed [ContactPaymentDelegate.send] + * or [TipPaymentDelegate.send] reports `State: Failure` with an `Error` on both "Sent Cash" and + * "Sent Tip". + */ +@OptIn(ExperimentalCoroutinesApi::class) +class ChatSendFailureAnalyticsTest { + + @get:Rule + var instantExecutorRule = InstantTaskExecutorRule() + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val chatCoordinator = mockk(relaxed = true) + private val contactCoordinator = mockk(relaxed = true) + private val contactPaymentDelegate = mockk(relaxed = true) + private val tipPaymentDelegate = mockk(relaxed = true) + private val transactionController = mockk(relaxed = true) + private val tokenCoordinator = mockk(relaxed = true) + private val exchange = mockk(relaxed = true) + private val verifiedFiatCalculator = mockk(relaxed = true) + private val purchaseMethodController = mockk(relaxed = true) + private val userManager = mockk(relaxed = true) + private val resources = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + private val clipboardManager = mockk(relaxed = true) + private val userFlags = mockk(relaxed = true) + private val linkCardClassifier = mockk(relaxed = true) + private val linkCardResolver = mockk(relaxed = true) + private val cashLinkClaims = mockk(relaxed = true) + private val chatDraftStore = mockk(relaxed = true) + + private val token = mockk(relaxed = true) + private val amount = Fiat(5.0) + private val verifiedFiat = VerifiedFiat(LocalFiat(Fiat(5.0), Fiat(5.0))) + + @Before + fun setUp() { + BottomBarManager.clear() + + every { userManager.accountCluster } returns mockk(relaxed = true) + every { exchange.preferredRate } returns Rate.oneToOne + every { transactionController.limits } returns MutableStateFlow(null) + every { tokenCoordinator.balanceForToken(any()) } returns Fiat(999.0) + every { tipPaymentDelegate.minimumToOpenDmWith(any()) } returns flowOf(null) + coEvery { + verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) + } returns Result.success(verifiedFiat) + coEvery { + contactCoordinator.resolve(any()) + } returns Result.success(mockk(relaxed = true)) + } + + @After + fun tearDown() { + BottomBarManager.clear() + } + + private fun createViewModel(): ChatViewModel = ChatViewModel( + chatCoordinator = chatCoordinator, + contactCoordinator = contactCoordinator, + contactPaymentDelegate = contactPaymentDelegate, + tipPaymentDelegate = tipPaymentDelegate, + transactionController = transactionController, + tokenCoordinator = tokenCoordinator, + exchange = exchange, + verifiedFiatCalculator = verifiedFiatCalculator, + purchaseMethodController = purchaseMethodController, + userManager = userManager, + resources = resources, + analytics = analytics, + clipboardManager = clipboardManager, + userFlags = userFlags, + linkCardClassifier = linkCardClassifier, + linkCardResolver = linkCardResolver, + cashLinkClaims = cashLinkClaims, + chatDraftStore = chatDraftStore, + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), + ) + + @Test + fun `a failed cash send reports State Failure with an Error`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { + contactPaymentDelegate.send(any(), any(), any(), any(), any()) + } returns Result.failure(RuntimeException("network down")) + + val contact = DeviceContact( + e164 = "+15551234567", + androidContactId = 1L, + displayName = "Ada Lovelace", + photoUri = null, + displayNumber = "(555) 123-4567", + ) + val chatId = ChatId(UUID.randomUUID().bytes) + + val vm = createViewModel() + vm.dispatchEvent(ChatViewModel.Event.ChatFound(chatId)) + vm.dispatchEvent(ChatViewModel.Event.OnContactFound(contact)) + vm.dispatchEvent(ChatViewModel.Event.OnSendRequested(amount, token)) + advanceUntilIdle() + + val event = analytics.events.single { it.name == "Sent Cash" } + assertEquals(PropertyValue.Text(AnalyticsState.FAILURE.value), event.properties["State"]) + assertTrue(event.properties.containsKey("Error")) + } + + @Test + fun `a failed tip send reports State Failure with an Error`() = runTest(mainCoroutineRule.dispatcher) { + coEvery { + tipPaymentDelegate.send(any(), any(), any(), any(), any(), any()) + } returns Result.failure(RuntimeException("network down")) + + val userId: ID = UUID.randomUUID().bytes + val profile = mockk(relaxed = true) + + val vm = createViewModel() + vm.dispatchEvent(ChatViewModel.Event.OnTipUserResolved(userId, profile)) + vm.dispatchEvent(ChatViewModel.Event.OnSendRequested(amount, token)) + advanceUntilIdle() + + val event = analytics.events.single { it.name == "Sent Tip" } + assertEquals(PropertyValue.Text(AnalyticsState.FAILURE.value), event.properties["State"]) + assertTrue(event.properties.containsKey("Error")) + } +} diff --git a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt index fafb3b6806..ac376c1ea1 100644 --- a/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt +++ b/apps/flipcash/features/scanner/src/main/kotlin/com/flipcash/app/scanner/internal/Scanner.kt @@ -22,7 +22,11 @@ import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalResources import androidx.compose.ui.res.stringResource import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.analytics.events.DeeplinkEvents +import com.flipcash.analytics.events.ScanEvents +import com.flipcash.app.analytics.analytics import com.flipcash.app.analytics.rememberAnalytics +import com.flipcash.app.analytics.withoutQueryOrFragment import com.flipcash.app.core.AppRoute import com.flipcash.app.core.AppRoute.Token.* import com.flipcash.app.core.chat.ChatIdentifier @@ -150,7 +154,11 @@ internal fun Scanner() { val firstScannableDeeplink = { urls: List -> urls.firstNotNullOfOrNull { url -> val type = router.classify(DeepLink(url)) - analytics.deeplinkParsed(type, url) + if (type != null) { + analytics.track(DeeplinkEvents.parsed(type.analytics)) + } else { + analytics.track(DeeplinkEvents.parseFailed(url.withoutQueryOrFragment())) + } type }?.takeIf { it.isScannable } } @@ -164,17 +172,19 @@ internal fun Scanner() { val onImagePicked = { uri: Uri -> scanJob.getAndSet(null)?.cancel() - analytics.galleryImagePicked() + analytics.track(ScanEvents.galleryImagePicked()) scanJob.set(scope.launch { isScanningStillImage = true val started = TimeSource.Monotonic.markNow() try { when (val result = kikCodeAnalyzer.detect(uri)) { is StaticImageResult.Found -> { - analytics.galleryScanSucceeded( - tier = result.tier, - zoom = result.zoom, - timeMillis = started.elapsedNow().inWholeMilliseconds, + analytics.track( + ScanEvents.gallerySucceeded( + tier = result.tier, + zoom = result.zoom.toDouble(), + timeMillis = started.elapsedNow().inWholeMilliseconds, + ) ) // No dedup to get past: `CodeScanDelegate` suppresses a rendezvous only // once a grab has succeeded, and clears it again on failure, so picking @@ -193,9 +203,11 @@ internal fun Scanner() { } else { // Reported after the QR fallback, so the event counts searches that // found nothing at all rather than searches the ladder missed. - analytics.galleryScanFailed( - timeMillis = started.elapsedNow().inWholeMilliseconds, - exhausted = result is StaticImageResult.Exhausted, + analytics.track( + ScanEvents.galleryFailed( + timeMillis = started.elapsedNow().inWholeMilliseconds, + exhausted = result is StaticImageResult.Exhausted, + ) ) showNoCodeFound() } diff --git a/apps/flipcash/features/tokens/build.gradle.kts b/apps/flipcash/features/tokens/build.gradle.kts index 25a1a368e5..e5739b5cb4 100644 --- a/apps/flipcash/features/tokens/build.gradle.kts +++ b/apps/flipcash/features/tokens/build.gradle.kts @@ -10,6 +10,7 @@ dependencies { implementation(project(":apps:flipcash:card-expand")) implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:onramp:coinbase")) implementation(project(":apps:flipcash:shared:onramp:deeplinks")) implementation(project(":apps:flipcash:shared:featureflags")) diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt index fb3199ebc9..112a1cfea5 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansion.kt @@ -24,6 +24,7 @@ import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment @@ -51,6 +52,13 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.Button +import com.flipcash.analytics.TokenInfoSource +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.analytics.events.TokenInfoEvents +import com.flipcash.app.analytics.analytics +import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.cardexpand.CardExpansionController import com.flipcash.app.core.AppRoute import com.flipcash.app.core.data.Loadable @@ -106,6 +114,19 @@ fun CurrencyInfoExpansion( viewModel.dispatchEvent(TokenInfoViewModel.Event.OnMintProvided(mint)) } + // One `Token Info: Opened From …` per open. The flag is saveable because a pushed action (Give / + // Convert / Withdraw) takes the wallet entry, and this overlay with it, out of composition; the + // entry's saved state outlives that, so returning doesn't count a second open. Closing the overlay + // drops it from composition for good, which clears the flag for the next open. + val analytics = rememberAnalytics() + var openTracked by rememberSaveable(mint) { mutableStateOf(false) } + LaunchedEffect(mint) { + if (!openTracked) { + openTracked = true + analytics.track(cardExpansionOpened(mint, fromDeckCard = controller.sourceBounds != null)) + } + } + // The wallet-tap detail is an app-root overlay, not a nav entry — so route its action tiles // (Give / Convert / Withdraw) through the reused VM's events the same way the pushed // TokenInfoScreen does. Pushed screens land ON the back stack and cover this overlay, which stays @@ -451,6 +472,7 @@ fun CurrencyInfoExpansion( endContent = { if (!state.isCashReserve) { AppBarDefaults.Share(hazeState = hazeState) { + analytics.track(ButtonEvents.tapped(Button.SHARE_TOKEN_INFO)) viewModel.dispatchEvent(TokenInfoViewModel.Event.Share) } } @@ -461,6 +483,17 @@ fun CurrencyInfoExpansion( } } +/** + * The `Token Info: Opened From …` event for an overlay open. A wallet tap flies the card out of its + * deck slot; a `/token` deeplink opens with no source card (CardExpansionController.beginExpanded). + * Mirrors iOS WalletScreen.openCard and openCardImmediately. + */ +internal fun cardExpansionOpened(mint: Mint, fromDeckCard: Boolean): AnalyticsEvent = + TokenInfoEvents.opened( + source = if (fromDeckCard) TokenInfoSource.WALLET else TokenInfoSource.DEEPLINK, + mint = mint.analytics, + ) + /** Drag distance at which the pull-to-close reaches full progress. */ private val PullFullDistance = 260.dp diff --git a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt index b3beef5b68..463133048a 100644 --- a/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt +++ b/apps/flipcash/features/tokens/src/main/kotlin/com/flipcash/app/tokens/TokenInfoScreen.kt @@ -31,8 +31,11 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.Button +import com.flipcash.analytics.Button +import com.flipcash.analytics.TokenInfoSource +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.analytics.events.TokenInfoEvents +import com.flipcash.app.analytics.analytics import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.SwapResult @@ -114,7 +117,7 @@ fun TokenInfoScreen( state.token.dataOrNull?.let { if (!state.isCashReserve) { AppBarDefaults.Share(hazeState = haze) { - analytics.buttonTapped(Button.TokenShare) + analytics.track(ButtonEvents.tapped(Button.SHARE_TOKEN_INFO)) viewModel.dispatchEvent(TokenInfoViewModel.Event.Share) } } @@ -172,14 +175,7 @@ fun TokenInfoScreen( } LaunchedEffect(Unit) { - val source = when (entry) { - TokenInfoEntry.Deeplink -> Analytics.TokenInfoSource.Deeplink - TokenInfoEntry.Wallet, - TokenInfoEntry.Discovery, - TokenInfoEntry.Chat, - TokenInfoEntry.ChatGate -> Analytics.TokenInfoSource.Wallet - } - analytics.openTokenInfo(source = source, mint = mint) + analytics.track(TokenInfoEvents.opened(entry.toTokenInfoSource(), mint.analytics)) } LaunchedEffect(Unit) { @@ -216,6 +212,20 @@ fun TokenInfoScreen( } } +/** + * Where a currency-info screen was opened from, for the shared `Token Info: Opened From …` + * event. Exhaustive with no `else`, so a new [TokenInfoEntry] fails to compile here rather than + * silently falling back to the wrong source. + */ +internal fun TokenInfoEntry.toTokenInfoSource(): TokenInfoSource = + when (this) { + TokenInfoEntry.Deeplink -> TokenInfoSource.DEEPLINK + TokenInfoEntry.Wallet -> TokenInfoSource.WALLET + TokenInfoEntry.Discovery -> TokenInfoSource.DISCOVERY + TokenInfoEntry.Chat -> TokenInfoSource.CHAT + TokenInfoEntry.ChatGate -> TokenInfoSource.CHAT_GATE + } + private enum class OverlaySlot { Bar, Content } /** diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansionAnalyticsTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansionAnalyticsTest.kt new file mode 100644 index 0000000000..c5a2e37df6 --- /dev/null +++ b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansionAnalyticsTest.kt @@ -0,0 +1,36 @@ +package com.flipcash.app.tokens + +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.PropertyValue +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.getcode.solana.keys.Mint +import kotlin.test.Test +import kotlin.test.assertEquals + +/** The wallet card-expand overlay sends the same open events iOS does for its currency-info card. */ +class CurrencyInfoExpansionAnalyticsTest { + + private val mint = Mint.usdc + private val properties = mapOf("Mint" to PropertyValue.Text(mint.analytics)) + + @Test + fun deckCardTapTracksOpenedFromWallet() = assertTracks( + fromDeckCard = true, + expectedName = "Token Info: Opened From Wallet", + ) + + @Test + fun sourcelessOpenTracksOpenedFromDeeplink() = assertTracks( + fromDeckCard = false, + expectedName = "Token Info: Opened From Deeplink", + ) + + private fun assertTracks(fromDeckCard: Boolean, expectedName: String) { + val analytics = RecordingAnalytics() + + analytics.track(cardExpansionOpened(mint, fromDeckCard)) + + assertEquals(listOf(AnalyticsEvent(expectedName, properties)), analytics.events) + } +} diff --git a/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenInfoScreenAnalyticsTest.kt b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenInfoScreenAnalyticsTest.kt new file mode 100644 index 0000000000..0b4cdc93b7 --- /dev/null +++ b/apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenInfoScreenAnalyticsTest.kt @@ -0,0 +1,63 @@ +package com.flipcash.app.tokens + +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.PropertyValue +import com.flipcash.analytics.events.TokenInfoEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.tokens.TokenInfoEntry +import com.getcode.solana.keys.Mint +import kotlin.test.Test +import kotlin.test.assertEquals + +/** + * Covers every [TokenInfoEntry], asserting the exact event Mixpanel receives. Discovery, Chat and + * ChatGate are the three entries that split off Wallet in this refactor; the other two keep their + * source unchanged. + */ +class TokenInfoScreenAnalyticsTest { + + private val mint = Mint.usdc + private val properties = mapOf("Mint" to PropertyValue.Text(mint.analytics)) + + @Test + fun deeplinkTracksOpenedFromDeeplink() = assertTracksSource( + entry = TokenInfoEntry.Deeplink, + expectedName = "Token Info: Opened From Deeplink", + ) + + @Test + fun walletTracksOpenedFromWallet() = assertTracksSource( + entry = TokenInfoEntry.Wallet, + expectedName = "Token Info: Opened From Wallet", + ) + + @Test + fun discoveryTracksOpenedFromDiscovery() = assertTracksSource( + entry = TokenInfoEntry.Discovery, + expectedName = "Token Info: Opened From Discovery", + ) + + @Test + fun chatTracksOpenedFromChat() = assertTracksSource( + entry = TokenInfoEntry.Chat, + expectedName = "Token Info: Opened From Chat", + ) + + @Test + fun chatGateTracksOpenedFromChatGate() = assertTracksSource( + entry = TokenInfoEntry.ChatGate, + expectedName = "Token Info: Opened From Chat Gate", + ) + + private fun assertTracksSource(entry: TokenInfoEntry, expectedName: String) { + val analytics = RecordingAnalytics() + + analytics.track(TokenInfoEvents.opened(entry.toTokenInfoSource(), mint.analytics)) + + assertEquals( + listOf(AnalyticsEvent(expectedName, properties)), + analytics.events, + ) + } +} diff --git a/apps/flipcash/features/user-profile/build.gradle.kts b/apps/flipcash/features/user-profile/build.gradle.kts index e4a83cc55c..15c5ad2dec 100644 --- a/apps/flipcash/features/user-profile/build.gradle.kts +++ b/apps/flipcash/features/user-profile/build.gradle.kts @@ -16,11 +16,13 @@ dependencies { implementation(project(":apps:flipcash:shared:amount-entry")) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:common-ui")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:payments")) implementation(project(":apps:flipcash:shared:userflags")) + implementation(project(":libs:coroutines")) implementation(project(":libs:messaging")) } diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt index 821fe07d18..b877aa95b7 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModel.kt @@ -3,11 +3,14 @@ package com.flipcash.app.userprofile.internal.name import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.events.DisplayNameEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.extensions.flatMapResult import com.flipcash.app.core.extensions.onResult import com.flipcash.features.userprofile.R +import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ModerationController import com.flipcash.services.controllers.ProfileController import com.flipcash.services.models.ModerationResult @@ -35,13 +38,15 @@ import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class NameEntryViewModel @Inject constructor( private val userManager: UserManager, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val moderationController: ModerationController, private val profileController: ProfileController, private val resources: ResourceHelper, + dispatchers: DispatcherProvider, ) : BaseViewModel( initialState = State(), - updateStateForEvent = updateStateForEvent + updateStateForEvent = updateStateForEvent, + defaultDispatcher = dispatchers.Default, ) { data class State( val nameFieldState: TextFieldState = TextFieldState(), @@ -141,7 +146,12 @@ class NameEntryViewModel @Inject constructor( stateFlow.value.nameFieldState.text.toString() ) result.onSuccess { - analytics.displayNameSubmitted(event.source, hadPreviousName) + val analyticsEvent = if (hadPreviousName) { + DisplayNameEvents.updated(event.source.analytics) + } else { + DisplayNameEvents.set(event.source.analytics) + } + analytics.track(analyticsEvent) } result }.onResult( @@ -238,4 +248,4 @@ class NameEntryViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt new file mode 100644 index 0000000000..f397c5ae1a --- /dev/null +++ b/apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt @@ -0,0 +1,104 @@ +package com.flipcash.app.userprofile.internal.name + +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.PropertyValue +import com.flipcash.analytics.events.DisplayNameEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics +import com.flipcash.app.core.DisplayNameSource +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.services.controllers.ModerationController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.models.UserProfile +import com.flipcash.services.user.UserManager +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever +import kotlin.test.assertEquals + +/** + * Covers the two branches [NameEntryViewModel] picks between on [NameEntryViewModel.Event.CheckName]: + * whether the account already had a display name (read from [UserManager.profile] before the + * write) decides `Display Name Set` vs `Display Name Updated`. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class NameEntryViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val userManager = mockk(relaxed = true) + private val analytics = RecordingAnalytics() + private val moderationController = mockk(relaxed = true) + // Mockito for the Result-returning suspend method (MockK double-boxes Result inline class) + private val profileController = mock() + private val resources = FakeResourceHelper() + + private lateinit var dispatchers: TestDispatchers + + private fun createViewModel() = NameEntryViewModel( + userManager = userManager, + analytics = analytics, + moderationController = moderationController, + profileController = profileController, + resources = resources, + dispatchers = dispatchers, + ) + + private fun profileNamed(name: String) = UserProfile( + displayName = name, + socialAccounts = emptyList(), + phoneNumber = null, + email = null, + ) + + @Test + fun `first name on an account without one tracks Display Name Set`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { userManager.state } returns MutableStateFlow(UserManager.State()) + every { userManager.profile } returns null + whenever(profileController.setDisplayName(any())).thenReturn(Result.success(Unit)) + + val vm = createViewModel() + vm.dispatchEvent(NameEntryViewModel.Event.CheckName(DisplayNameSource.Onboarding)) + advanceUntilIdle() + + assertEquals( + listOf(DisplayNameEvents.set(DisplayNameSource.Onboarding.analytics)), + analytics.events, + ) + } + + @Test + fun `replacing an existing name tracks the literal Display Name Updated event`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { userManager.state } returns MutableStateFlow(UserManager.State()) + every { userManager.profile } returns profileNamed("Ada") + whenever(profileController.setDisplayName(any())).thenReturn(Result.success(Unit)) + + val vm = createViewModel() + vm.dispatchEvent(NameEntryViewModel.Event.CheckName(DisplayNameSource.MyAccount)) + advanceUntilIdle() + + assertEquals( + listOf( + AnalyticsEvent( + "Display Name Updated", + mapOf("Source" to PropertyValue.Text("My Account")), + ) + ), + analytics.events, + ) + } +} diff --git a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt index d413acfb65..f195a4317d 100644 --- a/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt +++ b/apps/flipcash/features/withdrawal/src/main/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModel.kt @@ -5,9 +5,11 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.TransferEvents import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.extensions.onResult import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator @@ -73,7 +75,7 @@ internal class WithdrawalViewModel @Inject constructor( transactionController: TransactionOperations, clipboardManager: ClipboardManager, activityFeedCoordinator: ActivityFeedCoordinator, - analytics: FlipcashAnalyticsService, + analytics: FlipcashAnalytics, private val tokenCoordinator: TokenCoordinator, dispatchers: DispatcherProvider, ) : BaseViewModel( @@ -465,11 +467,8 @@ internal class WithdrawalViewModel @Inject constructor( ) }.onResult( onError = { - analytics.transfer( - event = Analytics.Transfer.Withdrawal, - amount = stateFlow.value.selectedAmount.localFiat, - successful = false, - error = it, + analytics.track( + TransferEvents.withdrawal(AnalyticsState.FAILURE, stateFlow.value.selectedAmount.localFiat.analytics, it.analytics) ) dispatchEvent(Event.UpdateWithdrawalState(loading = false)) BottomBarManager.showError( @@ -478,9 +477,8 @@ internal class WithdrawalViewModel @Inject constructor( ) }, onSuccess = { - analytics.transfer( - event = Analytics.Transfer.Withdrawal, - amount = stateFlow.value.selectedAmount.localFiat, + analytics.track( + TransferEvents.withdrawal(AnalyticsState.SUCCESS, stateFlow.value.selectedAmount.localFiat.analytics, null) ) viewModelScope.launch { coroutineScope { @@ -564,9 +562,8 @@ internal class WithdrawalViewModel @Inject constructor( ) }.onResult( onSuccess = { - analytics.transfer( - event = Analytics.Transfer.Withdrawal, - amount = stateFlow.value.selectedAmount.localFiat, + analytics.track( + TransferEvents.withdrawal(AnalyticsState.SUCCESS, stateFlow.value.selectedAmount.localFiat.analytics, null) ) viewModelScope.launch { coroutineScope { @@ -578,11 +575,8 @@ internal class WithdrawalViewModel @Inject constructor( } }, onError = { - analytics.transfer( - event = Analytics.Transfer.Withdrawal, - amount = stateFlow.value.selectedAmount.localFiat, - successful = false, - error = it, + analytics.track( + TransferEvents.withdrawal(AnalyticsState.FAILURE, stateFlow.value.selectedAmount.localFiat.analytics, it.analytics) ) dispatchEvent(Event.UpdateWithdrawalState(loading = false)) BottomBarManager.showError( @@ -679,4 +673,4 @@ internal class WithdrawalViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt index e3df7ad4fb..55219a93f5 100644 --- a/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt +++ b/apps/flipcash/features/withdrawal/src/test/kotlin/com/flipcash/app/withdrawal/WithdrawalViewModelErrorTest.kt @@ -2,7 +2,7 @@ package com.flipcash.app.withdrawal import android.content.ClipboardManager import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.user.UserManager @@ -38,7 +38,7 @@ class WithdrawalViewModelErrorTest { private val transactionController = mockk(relaxed = true) private val clipboardManager = mockk(relaxed = true) private val activityFeedCoordinator = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) private val userFlags = mockk(relaxed = true) diff --git a/apps/flipcash/shared/analytics/build.gradle.kts b/apps/flipcash/shared/analytics/build.gradle.kts index bd70f1bf4e..371f23ce36 100644 --- a/apps/flipcash/shared/analytics/build.gradle.kts +++ b/apps/flipcash/shared/analytics/build.gradle.kts @@ -4,19 +4,24 @@ plugins { android { namespace = "${Gradle.flipcashNamespace}.shared.anaylytics" + testFixtures { + enable = true + } } dependencies { + testFixturesImplementation(platform(libs.compose.bom)) + testFixturesImplementation(libs.compose.ui) + testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) implementation(libs.bugsnag) - implementation(libs.firebase.perf) - - api(project(":libs:analytics")) + api(project(":libs:analytics-events")) implementation(project(":services:flipcash")) implementation(project(":services:opencode")) diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Actions.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Actions.kt deleted file mode 100644 index 234e75f863..0000000000 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Actions.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.flipcash.app.analytics - -import com.getcode.libs.analytics.AppAction - -sealed interface Action : AppAction { - data object EnteredPhoneNumber : Action { - override val value: String = "Entered Phone Number" - } - data object VerifiedPhoneNumber: Action { - override val value: String = "Verified Phone Number" - } - - data object LinkedPhoneNumber: Action { - override val value: String = "Linked Phone Number" - } - - data object CompletedOnboarding : Action { - override val value: String = "Complete Onboarding" - } -} - -sealed interface Button: AppAction { - data object CreateAccount : Button { - override val value: String = "Button: Create Account" - } - - data object SaveAccessKey : Button { - override val value: String = "Button: Save Access Key" - } - - data object WroteAccessKey : Button { - override val value: String = "Button: Wrote Access Key" - } - - data object AllowCamera : Button { - override val value: String = "Button: Allow Camera" - } - - data object AllowPush : Button { - override val value: String = "Button: Allow Push" - } - - data object SkipPush : Button { - override val value: String = "Button: Skip Push" - } - - data object AllowContacts : Button { - override val value: String = "Button: Allow Contacts" - } - - data object SkipContacts: Button { - override val value: String = "Button: Skip Contacts" - } - - data object TokenBuyWithReserves : Button { - override val value: String = "Button: Buy With Reserves" - } - - data object TokenBuyWithPhantom : Button { - override val value: String = "Button: Buy With Phantom" - } - - data object TokenBuyWithCoinbase : Button { - override val value: String = "Button: Buy With Coinbase" - } - - data object TokenBuyWithOtherWallet : Button { - override val value: String = "Button: Buy With Other Wallet" - } - - data object TokenSell : Button { - override val value: String = "Button: Sell" - } - - data object TokenShare : Button { - override val value: String = "Button: Share Token Info" - } -} \ No newline at end of file diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt index 43cc0e10b6..f49b61b29f 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt @@ -1,169 +1,11 @@ package com.flipcash.app.analytics import androidx.compose.runtime.Composable -import com.flipcash.app.core.DisplayNameSource -import com.flipcash.app.core.navigation.DeeplinkType -import com.flipcash.services.internal.model.thirdparty.OnRampProvider -import com.flipcash.services.models.chat.ChatType -import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.libs.analytics.AnalyticsService -import com.getcode.libs.analytics.AppAction -import com.getcode.libs.analytics.AppActionSource -import com.getcode.libs.analytics.LocalAnalytics -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.solana.keys.Mint +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf -interface FlipcashAnalyticsService : AnalyticsService { - fun transferStart(event: Analytics.Transfer.Initiate) - fun transfer(event: Analytics.Transfer, amount: LocalFiat?, successful: Boolean = true, error: Throwable? = null) - fun transfer(event: Analytics.Transfer, fiat: Fiat?, successful: Boolean = true, error: Throwable? = null) - fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) - fun openOnramp(source: Analytics.OnrampSource) - fun onrampVerification(step: Analytics.OnrampVerificationStep) - fun onrampPurchase(step: Analytics.OnrampPurchaseStep, amount: Fiat? = null) - fun addMoneyOpened(source: Analytics.AddMoneySource) - fun addMoneyMethodSelected(method: Analytics.AddMoneyMethod) - fun addMoneyAmountConfirmed(method: Analytics.AddMoneyMethod, amount: Fiat) - fun addMoneyPaymentInvoked(method: Analytics.AddMoneyMethod, amount: Fiat) - fun addMoneyAddressCopied(mint: Mint) - fun addMoney(method: Analytics.AddMoneyMethod, amount: Fiat? = null, successful: Boolean = true, error: Throwable? = null) - fun connectWallet(provider: OnRampProvider.UsesDeeplinks) - fun amountSelectedForWalletTransfer(provider: OnRampProvider.UsesDeeplinks, amount: Fiat) - fun transactionSubmittedToWallet(provider: OnRampProvider.UsesDeeplinks) - fun walletTransactionFailed(provider: OnRampProvider.UsesDeeplinks) - fun walletTransactionCancelled(provider: OnRampProvider.UsesDeeplinks) - fun openTokenInfo(source: Analytics.TokenInfoSource, mint: Mint) - fun buy(method: Analytics.PurchaseMethod, mint: Mint, amount: Fiat, error: Throwable? = null) - fun sell(mint: Mint, amount: Fiat, feeAmount: Fiat, error: Throwable? = null) - fun messageSentInChat(type: ChatType, error: Throwable? = null) - fun tipCardScanned() - - /** An image was picked from the gallery and the still-image search started. */ - fun galleryImagePicked() - - /** @param tier which rung of the crop ladder decoded, 1 to 3. */ - fun galleryScanSucceeded(tier: Int, zoom: Float, timeMillis: Long) - - /** @param exhausted true when the budget ran out rather than the ladder ending. */ - fun galleryScanFailed(timeMillis: Long, exhausted: Boolean) - fun tipCardPresented() - fun deeplinkOpened(url: String) - fun deeplinkParsed(type: DeeplinkType?, url: String) - fun deeplinkRouted(type: DeeplinkType, error: Throwable? = null) - fun displayedErrorModal(title: String, message: String, screen: String? = null, callSite: String? = null) - - /** @param hadPreviousName true when the user is replacing a name, false on first set. */ - fun displayNameSubmitted(source: DisplayNameSource, hadPreviousName: Boolean) - - /** Increments a cumulative per-user counter. [amount] defaults to a single occurrence. */ - fun incrementReceivedCounter(counter: Analytics.ReceivedCounter, amount: Double = 1.0) - - fun tipReceived(chatType: ChatType, amount: Fiat, mint: Mint) - fun messageReceived(chatType: ChatType) - - fun buttonTapped(button: Button) { - action(button) - } -} - -object Analytics { - - /** - * Cumulative per-user counters stored as Mixpanel people properties. - * - * These are incremented once per received message and are NOT idempotent — - * every caller must be behind the analytics watermark. See - * ChatMetadataDataSource.getAnalyticsCountedThrough. - */ - enum class ReceivedCounter { Tips, TipsValue, Messages } - - sealed interface Transfer { - sealed interface Initiate: Transfer { - data object GrabBillStart: Initiate - data object GiveBillStart: Initiate - } - - data class GrabBill(val time: Long? = null) : Transfer - data object GiveBill : Transfer - data object Withdrawal : Transfer - data object ClaimedCashLink : Transfer - sealed interface SentCashLink : Transfer { - data object Clipboard : SentCashLink - data class App(val name: String) : SentCashLink - } - - data object SentCash : Transfer - data object SentTip : Transfer - } - enum class OnrampSource { Settings, Balance, Give } - enum class AddMoneySource { Menu, GiveShortfall, BuyShortfall, UsernameShortfall, Chat, Scanner, Balance } - enum class AddMoneyMethod { Coinbase, Phantom, OtherWallet, Reserves } - enum class OnrampVerificationStep { ShowInfo, EnterPhone, ConfirmPhone, EnterEmail, ConfirmEmail } - enum class OnrampPurchaseStep { PresetSelected, EnterCustomAmount, InvokePayment, InvokePaymentCustom, Completed } - enum class TokenInfoSource { Deeplink, Wallet, Give } - enum class PurchaseMethod { Reserves, Phantom, Coinbase } - sealed interface SwapMethod { - enum class Buy(val with: PurchaseMethod) : SwapMethod { - Reserves(PurchaseMethod.Reserves), - Phantom(PurchaseMethod.Phantom), - Coinbase(PurchaseMethod.Coinbase) - } - data object Sell : SwapMethod - } -} - -class StubFlipcashAnalytics : FlipcashAnalyticsService { - override fun onAppStart() = Unit - override fun onAppStarted() = Unit - override fun unintentionalLogout() = Unit - override fun action(action: AppAction, source: AppActionSource?) = Unit - - override fun transferStart(event: Analytics.Transfer.Initiate) = Unit - override fun transfer(event: Analytics.Transfer, amount: LocalFiat?, successful: Boolean, error: Throwable?) = Unit - override fun transfer(event: Analytics.Transfer, fiat: Fiat?, successful: Boolean, error: Throwable?) = Unit - override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) = Unit - - override fun openOnramp(source: Analytics.OnrampSource) = Unit - override fun onrampVerification(step: Analytics.OnrampVerificationStep) = Unit - override fun onrampPurchase(step: Analytics.OnrampPurchaseStep, amount: Fiat?) = Unit - - override fun addMoneyOpened(source: Analytics.AddMoneySource) = Unit - override fun addMoneyMethodSelected(method: Analytics.AddMoneyMethod) = Unit - override fun addMoneyAmountConfirmed(method: Analytics.AddMoneyMethod, amount: Fiat) = Unit - override fun addMoneyPaymentInvoked(method: Analytics.AddMoneyMethod, amount: Fiat) = Unit - override fun addMoneyAddressCopied(mint: Mint) = Unit - override fun addMoney(method: Analytics.AddMoneyMethod, amount: Fiat?, successful: Boolean, error: Throwable?) = Unit - - override fun connectWallet(provider: OnRampProvider.UsesDeeplinks) = Unit - override fun amountSelectedForWalletTransfer(provider: OnRampProvider.UsesDeeplinks, amount: Fiat) = Unit - override fun transactionSubmittedToWallet(provider: OnRampProvider.UsesDeeplinks) = Unit - override fun walletTransactionFailed(provider: OnRampProvider.UsesDeeplinks) = Unit - override fun walletTransactionCancelled(provider: OnRampProvider.UsesDeeplinks) = Unit - - override fun openTokenInfo(source: Analytics.TokenInfoSource, mint: Mint) = Unit - override fun buy(method: Analytics.PurchaseMethod, mint: Mint, amount: Fiat, error: Throwable?) = Unit - override fun sell(mint: Mint, amount: Fiat, feeAmount: Fiat, error: Throwable?) = Unit - - override fun messageSentInChat(type: ChatType, error: Throwable?) = Unit - override fun tipCardScanned() = Unit - override fun galleryImagePicked() = Unit - override fun galleryScanSucceeded(tier: Int, zoom: Float, timeMillis: Long) = Unit - override fun galleryScanFailed(timeMillis: Long, exhausted: Boolean) = Unit - override fun tipCardPresented() = Unit - - override fun deeplinkOpened(url: String) = Unit - override fun deeplinkParsed(type: DeeplinkType?, url: String) = Unit - override fun deeplinkRouted(type: DeeplinkType, error: Throwable?) = Unit - override fun displayedErrorModal(title: String, message: String, screen: String?, callSite: String?) = Unit - override fun displayNameSubmitted(source: DisplayNameSource, hadPreviousName: Boolean) = Unit - override fun incrementReceivedCounter(counter: Analytics.ReceivedCounter, amount: Double) = Unit - override fun tipReceived(chatType: ChatType, amount: Fiat, mint: Mint) = Unit - override fun messageReceived(chatType: ChatType) = Unit -} +val LocalAnalytics: ProvidableCompositionLocal = + staticCompositionLocalOf { FlipcashAnalytics.None } @Composable -fun rememberAnalytics(): FlipcashAnalyticsService { - return LocalAnalytics.current as FlipcashAnalyticsService -} \ No newline at end of file +fun rememberAnalytics(): FlipcashAnalytics = LocalAnalytics.current diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt new file mode 100644 index 0000000000..e14ec8189f --- /dev/null +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt @@ -0,0 +1,97 @@ +package com.flipcash.app.analytics + +import androidx.core.net.toUri +import com.flipcash.analytics.Amount +import com.flipcash.analytics.WalletProvider +import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.services.internal.model.thirdparty.OnRampProvider +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 +import com.flipcash.analytics.ChatType as AnalyticsChatType +import com.flipcash.analytics.DisplayNameSource as AnalyticsDisplayNameSource +import com.flipcash.app.core.DisplayNameSource +import com.flipcash.services.models.chat.ChatType + +/* + * Converts Android domain types into what the shared builders in `:libs:analytics-events` + * take. This is the only place Android-specific formatting of an analytics value lives. + */ + +val ChatType.analytics: AnalyticsChatType + get() = when (this) { + ChatType.CONTACT_DM -> AnalyticsChatType.CONTACT + ChatType.TIP_DM -> AnalyticsChatType.TIP + ChatType.GROUP -> AnalyticsChatType.GROUP + ChatType.UNKNOWN -> AnalyticsChatType.UNKNOWN + } + +/** Android's `Error` format. iOS sends `domain.error:code`; part 2 settles one. */ +val Throwable.analytics: String + get() = message.orEmpty() + +/** The native amount and currency, with the token amount as `USDC` and `Quarks`. */ +val LocalFiat.analytics: Amount + get() = Amount( + fiat = nativeAmount.decimalValue, + currency = rate.currency.name, + usdc = underlyingTokenAmount.decimalValue, + quarks = underlyingTokenAmount.quarks, + exchangeRate = rate.fx, + mint = mint.base58(), + ) + +/** No exchange rate or mint; `USDC` repeats the amount. */ +val Fiat.analytics: Amount + get() = Amount( + fiat = decimalValue, + currency = currencyCode.name, + usdc = decimalValue, + quarks = quarks, + ) + +val Mint.analytics: String + get() = base58() + +val OnRampProvider.UsesDeeplinks.analytics: WalletProvider + get() = when (this) { + OnRampProvider.Phantom -> WalletProvider.PHANTOM + } + +val DisplayNameSource.analytics: AnalyticsDisplayNameSource + get() = when (this) { + DisplayNameSource.Onboarding -> AnalyticsDisplayNameSource.ONBOARDING + DisplayNameSource.MyAccount -> AnalyticsDisplayNameSource.MY_ACCOUNT + DisplayNameSource.TipCardSetup -> AnalyticsDisplayNameSource.TIP_CARD_SETUP + } + +/** + * The deeplink's `Type` property. Spelled out rather than taken from `javaClass.simpleName`, + * which R8 renames in release builds. + */ +val DeeplinkType.analytics: String + get() = when (this) { + is DeeplinkType.Login -> "Login" + is DeeplinkType.CashLink -> "CashLink" + is DeeplinkType.TokenInfo -> "TokenInfo" + is DeeplinkType.TipChat -> "TipChat" + is DeeplinkType.GroupChatInvite -> "GroupChatInvite" + is DeeplinkType.Tipcard -> "Tipcard" + is DeeplinkType.TipcardByUsername -> "TipcardByUsername" + is DeeplinkType.EmailVerification -> "EmailVerification" + } + +/** The URL with its query and fragment removed, which can carry secrets. */ +fun String.withoutQueryOrFragment(): String { + val uri = toUri() + return try { + uri.buildUpon() + .clearQuery() + .fragment(null) + .build() + .toString() + } catch (_: Exception) { + uri.path ?: this + } +} diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt deleted file mode 100644 index 4b41386dac..0000000000 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt +++ /dev/null @@ -1,506 +0,0 @@ -package com.flipcash.app.analytics - -import androidx.core.net.toUri -import com.flipcash.app.core.DisplayNameSource -import com.flipcash.app.core.navigation.DeeplinkType -import com.flipcash.services.internal.model.thirdparty.OnRampProvider -import com.flipcash.services.models.chat.ChatType -import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.opencode.model.core.ID -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.solana.keys.Mint -import com.getcode.solana.keys.base58 -import com.getcode.utils.base58 -import com.getcode.utils.getPublicKeyBase58 - -internal sealed interface AnalyticsEvent { - val name: String - fun toProperties(): Map = emptyMap() - - sealed interface DisplayNameEvent : AnalyticsEvent { - val source: DisplayNameSource - override fun toProperties() = mapOf("Source" to source.propertyValue) - - /** The user had no display name before this submission. */ - data class Set(override val source: DisplayNameSource) : DisplayNameEvent { - override val name = "Display Name Set" - } - - /** The user replaced an existing display name. */ - data class Updated(override val source: DisplayNameSource) : DisplayNameEvent { - override val name = "Display Name Updated" - } - } - - data class PaidForAccount( - val price: Double, - val currency: CurrencyCode, - val owner: KeyPair - ) : AnalyticsEvent { - override val name = "Create Account Payment" - override fun toProperties() = mapOf( - "Fiat" to price.toString(), - "Currency" to currency.name, - "Owner Public Key" to owner.getPublicKeyBase58() - ) - } - - data class ErrorModalDisplayed( - val title: String, - val message: String, - val screen: String? = null, - val callSite: String? = null, - ): AnalyticsEvent { - override val name = "Error Modal Displayed" - override fun toProperties() = buildMap { - put("Title", title) - put("Message", message) - screen?.let { put("Screen", it) } - callSite?.let { put("Call Site", it) } - } - } - - sealed interface DeeplinkEvent : AnalyticsEvent { - data class Open(val url: String) : DeeplinkEvent { - override val name = "Deeplink: Open" - override fun toProperties() = mapOf("URL" to url.sanitized()) - - private fun String.sanitized(): String { - val uri = this.toUri() - return try { - uri.buildUpon() - .clearQuery() - .fragment(null) - .build() - .toString() - } catch (_: Exception) { - uri.path ?: url - } - } - } - - data class Parse( - val type: DeeplinkType? = null, - val url: String, - ) : DeeplinkEvent { - override val name = "Deeplink: Parse" - override fun toProperties() = buildMap { - type?.let { put("Type", it.javaClass.simpleName) } - if (type == null) { - put("Error", "Failed to parse deeplink => $url") - } - } - } - - data class Routed( - val type: DeeplinkType, - val error: Throwable? = null - ) : DeeplinkEvent { - override val name = "Deeplink: Routed" - override fun toProperties() = buildMap { - put("Type", type.javaClass.simpleName) - error?.let { put("Error", it.message.orEmpty()) } - } - } - } - - sealed interface Transfer : AnalyticsEvent - - data object GrabBillStart: Transfer { - override val name = "Grab Bill Start" - } - - data class GrabBill(val time: Long?) : Transfer { - override val name = "Grab Bill" - override fun toProperties() = buildMap { - time?.let { put("Grab Time", it.toString()) } - } - } - - data object GiveBill : Transfer { - override val name = "Give Bill" - } - - data object GiveBillStart : Transfer { - override val name = "Give Bill Start" - } - - data object Withdrawal : Transfer { - override val name = "Withdrawal" - } - - data class SentCashLink( - val clipboard: Boolean? = null, - val app: String? = null - ) : Transfer { - override val name = "Send Cash Link" - override fun toProperties() = buildMap { - if (clipboard == true) { - put("Cash Link Choice", "Copied to clipboard") - } else if (app != null) { - put("Cash Link Choice", "Shared to app") - put("App", app) - } - } - } - - data object ClaimedCashLink : Transfer { - override val name = "Receive Cash Link" - } - - data object SentTip : Transfer { - override val name = "Sent Tip" - } - - data object SentCash : ChatEvent { - override val name = "Sent Cash" - } - - sealed interface ChatEvent: AnalyticsEvent { - data class SentMessage( - val chatType: ChatType, - val error: Throwable? = null - ): ChatEvent { - override val name = "Sent Message" - - override fun toProperties() = buildMap { - put("Chat Type", chatType.propertyValue) - error?.let { put("Error", it.message.orEmpty()) } - } - } - - data class TipReceived( - val chatType: ChatType, - val amount: Fiat, - val mint: Mint, - ) : ChatEvent { - override val name = "Tip Received" - override fun toProperties() = buildMap { - put("Chat Type", chatType.propertyValue) - putAll(amount.asProperties()) - // Token Symbol is added centrally by the delegate. - put("Mint", mint.base58()) - } - } - - data class MessageReceived(val chatType: ChatType) : ChatEvent { - override val name = "Message Received" - override fun toProperties() = mapOf("Chat Type" to chatType.propertyValue) - } - } - - /** - * The gallery scan path, end to end. - * - * [Succeeded] reports the tier and zoom that decoded and [Failed] the time a fruitless search - * took, which together are what would let the ladder's inherited constants be trimmed — they - * were carried over from Code and have never been measured against real screenshots. - */ - sealed interface GalleryScanEvent : AnalyticsEvent { - data object ImagePicked : GalleryScanEvent { - override val name = "Gallery Scan: Image Picked" - } - - data class Succeeded( - val tier: Int, - val zoom: Float, - val time: Long, - ) : GalleryScanEvent { - override val name = "Gallery Scan: Succeeded" - override fun toProperties() = mapOf( - "Tier" to tier.toString(), - "Zoom" to zoom.toString(), - "Time" to time.toString(), - ) - } - - data class Failed( - val time: Long, - /** True when the budget ran out rather than the ladder being walked to the end. */ - val exhausted: Boolean, - ) : GalleryScanEvent { - override val name = "Gallery Scan: Failed" - override fun toProperties() = mapOf( - "Time" to time.toString(), - "Exhausted" to exhausted.toString(), - ) - } - } - - sealed interface TipCardEvent : AnalyticsEvent { - data object Scanned : TipCardEvent { - override val name = "Tip Card Scanned" - } - - data object Presented : TipCardEvent { - override val name = "Tip Card Presented" - } - } - - sealed interface PoolEvent : AnalyticsEvent { - val id: ID - override fun toProperties() = mapOf("ID" to id.base58) - } - - data class PoolOpened(override val id: ID) : PoolEvent { - override val name = "Pool: Opened From Deeplink" - } - - data class PoolCreated(override val id: ID) : PoolEvent { - override val name = "Pool: Created" - } - - data class PlacedBid(override val id: ID) : PoolEvent { - override val name = "Pool: Place Bet" - } - - data class DeclaredOutcome(override val id: ID) : PoolEvent { - override val name = "Pool: Declared Outcome" - } - - sealed interface WalletEvent : AnalyticsEvent { - val provider: OnRampProvider.UsesDeeplinks - val providerName get() = when (provider) { - OnRampProvider.Phantom -> "Phantom" - } - } - - data class WalletConnect(override val provider: OnRampProvider.UsesDeeplinks) : WalletEvent { - override val name = "Wallet: Connect" - override fun toProperties() = mapOf("Provider" to providerName) - } - - data class WalletRequestAmount( - override val provider: OnRampProvider.UsesDeeplinks, - val amount: Fiat - ) : WalletEvent { - override val name = "Wallet: Request Amount" - override fun toProperties() = buildMap { - put("Provider", providerName) - putAll(amount.asProperties()) - } - } - - data class WalletSubmitTransaction(override val provider: OnRampProvider.UsesDeeplinks) : WalletEvent { - override val name = "Wallet: Transactions Submitted" - override fun toProperties() = mapOf("Provider" to providerName) - } - - data class WalletTransactionFailed(override val provider: OnRampProvider.UsesDeeplinks) : WalletEvent { - override val name = "Wallet: Transactions Failed" - override fun toProperties() = mapOf("Provider" to providerName) - } - - data class WalletTransactionCancelled(override val provider: OnRampProvider.UsesDeeplinks) : WalletEvent { - override val name = "Wallet: Cancel" - override fun toProperties() = mapOf("Provider" to providerName) - } - - sealed interface OnRampOpenEvent : AnalyticsEvent { - data object Settings : OnRampOpenEvent { override val name = "Onramp: Opened From Settings" } - data object Balance : OnRampOpenEvent { override val name = "Onramp: Opened From Balance" } - data object Give : OnRampOpenEvent { override val name = "Onramp: Opened From Give" } - } - - sealed interface OnRampVerificationEvent : AnalyticsEvent { - data object ShowInfo : OnRampVerificationEvent { override val name = "Onramp: Show Verification Info" } - data object EnterPhone : OnRampVerificationEvent { override val name = "Onramp: Show Enter Phone" } - data object ConfirmPhone : OnRampVerificationEvent { override val name = "Onramp: Show Confirm Phone" } - data object EnterEmail : OnRampVerificationEvent { override val name = "Onramp: Show Enter Email" } - data object ConfirmEmail : OnRampVerificationEvent { override val name = "Onramp: Show Confirm Email" } - } - - sealed interface OnRampPurchaseEvent : AnalyticsEvent { - data object PresetSelected : OnRampPurchaseEvent { override val name = "Onramp: Amount Selected" } - data object EnterCustomAmount : OnRampPurchaseEvent { override val name = "Onramp: Enter Custom Amount" } - - data class InvokePayment(val amount: Fiat) : OnRampPurchaseEvent { - override val name = "Onramp: Invoke Payment" - override fun toProperties() = amount.asProperties() - } - - data class InvokePaymentCustom(val amount: Fiat) : OnRampPurchaseEvent { - override val name = "Onramp: Invoke Payment Custom" - override fun toProperties() = amount.asProperties() - } - - data class Completed(val amount: Fiat) : OnRampPurchaseEvent { - override val name = "Onramp: Completed" - override fun toProperties() = amount.asProperties() - } - } - - sealed interface AddMoneyEvent : AnalyticsEvent { - data class Opened(val source: Analytics.AddMoneySource) : AddMoneyEvent { - override val name = "Add Money: Opened" - override fun toProperties() = mapOf("Source" to source.propertyValue) - } - - data class MethodSelected(val method: Analytics.AddMoneyMethod) : AddMoneyEvent { - override val name = "Add Money: Method Selected" - override fun toProperties() = mapOf("Method" to method.propertyValue) - } - - data class AmountConfirmed( - val method: Analytics.AddMoneyMethod, - val amount: Fiat, - ) : AddMoneyEvent { - override val name = "Add Money: Amount Confirmed" - override fun toProperties() = buildMap { - put("Method", method.propertyValue) - putAll(amount.asProperties()) - } - } - - data class PaymentInvoked( - val method: Analytics.AddMoneyMethod, - val amount: Fiat, - ) : AddMoneyEvent { - override val name = "Add Money: Payment Invoked" - override fun toProperties() = buildMap { - put("Method", method.propertyValue) - putAll(amount.asProperties()) - } - } - - data class AddressCopied(val mint: Mint) : AddMoneyEvent { - override val name = "Add Money: Address Copied" - override fun toProperties() = mapOf("Mint" to mint.base58()) - } - - data class Terminal(val method: Analytics.AddMoneyMethod) : AddMoneyEvent { - override val name = "Add Money" - override fun toProperties() = mapOf("Method" to method.propertyValue) - } - } - - sealed interface OpenTokenInfoEvent : AnalyticsEvent { - val mint: Mint - override fun toProperties() = mapOf("Mint" to mint.base58()) - - data class Deeplink(override val mint: Mint) : OpenTokenInfoEvent { - override val name = "Token Info: Opened From Deeplink" - } - data class Wallet(override val mint: Mint) : OpenTokenInfoEvent { - override val name = "Token Info: Opened From Wallet" - } - data class Give(override val mint: Mint) : OpenTokenInfoEvent { - override val name = "Token Info: Opened From Give" - } - } - - sealed interface TokenTransactionEvent : AnalyticsEvent { - val mint: Mint - val amount: Fiat - val error: Throwable? - - override fun toProperties() = buildMap { - put("Mint", mint.base58()) - putAll(amount.asProperties()) - error?.let { put("Error", it.message.orEmpty()) } - } - - sealed interface Purchase : TokenTransactionEvent { - data class Reserves(override val mint: Mint, override val amount: Fiat, override val error: Throwable? = null) : Purchase { - override val name = "Token Purchase With Reserves" - } - data class Phantom(override val mint: Mint, override val amount: Fiat, override val error: Throwable? = null) : Purchase { - override val name = "Token Purchase With Phantom" - } - data class Coinbase(override val mint: Mint, override val amount: Fiat, override val error: Throwable? = null) : Purchase { - override val name = "Token Purchase With Coinbase" - } - } - - data class Sell( - override val mint: Mint, - override val amount: Fiat, - val feeAmount: Fiat, - override val error: Throwable? = null - ) : TokenTransactionEvent { - override val name = "Token Sell" - override fun toProperties() = buildMap { - put("Mint", mint.base58()) - putAll(amount.asProperties()) - put("Fee", feeAmount.decimalValue.toString()) - error?.let { put("Error", it.message.orEmpty()) } - } - } - } -} - -internal val Analytics.AddMoneySource.propertyValue: String - get() = when (this) { - Analytics.AddMoneySource.Menu -> "Menu" - Analytics.AddMoneySource.GiveShortfall -> "Give Shortfall" - Analytics.AddMoneySource.BuyShortfall -> "Buy Shortfall" - Analytics.AddMoneySource.UsernameShortfall -> "Username Shortfall" - Analytics.AddMoneySource.Chat -> "Chat" - Analytics.AddMoneySource.Scanner -> "Scanner" - Analytics.AddMoneySource.Balance -> "Balance" - } - -internal val Analytics.AddMoneyMethod.propertyValue: String - get() = when (this) { - Analytics.AddMoneyMethod.Coinbase -> "Coinbase" - Analytics.AddMoneyMethod.Phantom -> "Phantom" - Analytics.AddMoneyMethod.OtherWallet -> "Other Wallet" - Analytics.AddMoneyMethod.Reserves -> "Reserves" - } - -internal val ChatType.propertyValue: String - get() = when (this) { - ChatType.CONTACT_DM -> "Contact" - ChatType.TIP_DM -> "Tip" - ChatType.GROUP -> "Group" - ChatType.UNKNOWN -> "Unknown" - } - -internal fun LocalFiat.asProperties(): Map { - return buildMap { - putAll(underlyingTokenAmount.asProperties()) - put("Fiat", nativeAmount.decimalValue.toString()) - put("Exchange Rate", rate.fx.toString()) - put("Currency", rate.currency.name) - put("Mint", mint.base58()) - } -} - - -internal fun Fiat.asProperties(): Map { - return buildMap { - put("Fiat", decimalValue.toString()) - put("Currency", currencyCode.name) - put("USDC", decimalValue.toString()) - put("Quarks", quarks.toDouble().toString()) - } -} - -internal fun Analytics.Transfer.toAnalyticsEvent(): AnalyticsEvent = when (this) { - is Analytics.Transfer.Initiate.GrabBillStart -> AnalyticsEvent.GrabBillStart - is Analytics.Transfer.GrabBill -> AnalyticsEvent.GrabBill(time = time) - is Analytics.Transfer.Initiate.GiveBillStart -> AnalyticsEvent.GiveBillStart - is Analytics.Transfer.GiveBill -> AnalyticsEvent.GiveBill - is Analytics.Transfer.Withdrawal -> AnalyticsEvent.Withdrawal - is Analytics.Transfer.ClaimedCashLink -> AnalyticsEvent.ClaimedCashLink - is Analytics.Transfer.SentCashLink.Clipboard -> AnalyticsEvent.SentCashLink(clipboard = true) - is Analytics.Transfer.SentCashLink.App -> AnalyticsEvent.SentCashLink(app = name) - is Analytics.Transfer.SentCash -> AnalyticsEvent.SentCash - is Analytics.Transfer.SentTip -> AnalyticsEvent.SentTip -} - -internal val DisplayNameSource.propertyValue: String - get() = when (this) { - DisplayNameSource.Onboarding -> "Onboarding" - DisplayNameSource.MyAccount -> "My Account" - DisplayNameSource.TipCardSetup -> "Tip Card Setup" - } - -internal val Analytics.ReceivedCounter.propertyValue: String - get() = when (this) { - Analytics.ReceivedCounter.Tips -> "Tips Received" - Analytics.ReceivedCounter.TipsValue -> "Tips Received Value" - Analytics.ReceivedCounter.Messages -> "Messages Received" - } diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/FlipcashAnalytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/FlipcashAnalytics.kt new file mode 100644 index 0000000000..e01428c1ea --- /dev/null +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/FlipcashAnalytics.kt @@ -0,0 +1,22 @@ +package com.flipcash.app.analytics + +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.PeopleCounter + +/** Sends shared analytics events. Every name and key comes from `:libs:analytics-events`. */ +interface FlipcashAnalytics { + fun track(event: AnalyticsEvent) + + /** + * Adds to a cumulative people property. It can't be undone and carries no event identity, + * so every caller must sit behind a watermark (see `EventStreamDelegate`). + */ + fun increment(counter: PeopleCounter, amount: Double = 1.0) + + companion object { + val None: FlipcashAnalytics = object : FlipcashAnalytics { + override fun track(event: AnalyticsEvent) = Unit + override fun increment(counter: PeopleCounter, amount: Double) = Unit + } + } +} diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/inject/AnalyticsModule.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/inject/AnalyticsModule.kt index cfea26593e..f63add4866 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/inject/AnalyticsModule.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/inject/AnalyticsModule.kt @@ -1,6 +1,6 @@ package com.flipcash.app.analytics.inject -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.analytics.TokenSymbolResolver import com.flipcash.app.analytics.internal.MixpanelAnalyticsDelegate import com.mixpanel.android.mpmetrics.MixpanelAPI @@ -13,8 +13,8 @@ import dagger.hilt.components.SingletonComponent @InstallIn(SingletonComponent::class) object AnalyticsModule { @Provides - fun providesAnalyticsService( + fun providesFlipcashAnalytics( mixpanelAPI: MixpanelAPI, tokenSymbolResolver: TokenSymbolResolver, - ): FlipcashAnalyticsService = MixpanelAnalyticsDelegate(mixpanelAPI, tokenSymbolResolver) + ): FlipcashAnalytics = MixpanelAnalyticsDelegate(mixpanelAPI, tokenSymbolResolver) } diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt index a18a0203ae..39408e19a3 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt @@ -1,29 +1,13 @@ package com.flipcash.app.analytics.internal -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.AnalyticsEvent -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.PeopleCounter +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.analytics.PropertyValue import com.flipcash.app.analytics.TokenSymbolResolver -import com.flipcash.app.analytics.asProperties -import com.flipcash.app.analytics.propertyValue -import com.flipcash.app.analytics.toAnalyticsEvent -import com.flipcash.app.core.DisplayNameSource -import com.flipcash.app.core.navigation.DeeplinkType -import com.flipcash.services.internal.model.thirdparty.OnRampProvider -import com.flipcash.services.models.chat.ChatType -import com.getcode.ed25519.Ed25519.KeyPair -import com.getcode.libs.analytics.AppAction -import com.getcode.libs.analytics.AppActionSource -import com.getcode.opencode.model.financial.CurrencyCode -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.LocalFiat import com.getcode.services.flipcash.BuildConfig -import com.getcode.solana.keys.Mint import com.getcode.utils.TraceType import com.getcode.utils.trace -import com.google.firebase.Firebase -import com.google.firebase.perf.metrics.Trace -import com.google.firebase.perf.performance import com.mixpanel.android.mpmetrics.MixpanelAPI import org.json.JSONObject import javax.inject.Inject @@ -31,286 +15,32 @@ import javax.inject.Inject internal class MixpanelAnalyticsDelegate @Inject constructor( private val mixpanelAPI: MixpanelAPI, private val tokenSymbolResolver: TokenSymbolResolver, -) : FlipcashAnalyticsService { +) : FlipcashAnalytics { - private var traceAppInit: Trace? = null - private var timeAppInit: Long? = null + override fun track(event: AnalyticsEvent) { + val properties = event.toMixpanelProperties(tokenSymbolResolver) - override fun onAppStart() { - timeAppInit = System.currentTimeMillis() - traceAppInit = Firebase.performance.newTrace("Init") - traceAppInit?.start() - } - - override fun onAppStarted() { - traceAppInit ?: return - traceAppInit?.stop() - traceAppInit = null - val duration = System.currentTimeMillis() - (timeAppInit ?: 0) - trace( - tag = "Analytics", - message = "App started", - metadata = { "duration" to duration }, - type = TraceType.Process - ) - } - - override fun unintentionalLogout() = Unit - - override fun action(action: AppAction, source: AppActionSource?) { - track(name = action.value) - } - - override fun transferStart(event: Analytics.Transfer.Initiate) { - track(event.toAnalyticsEvent()) - } - - override fun transfer( - event: Analytics.Transfer, - amount: LocalFiat?, - successful: Boolean, - error: Throwable? - ) { - track( - event.toAnalyticsEvent(), - "State" to if (successful) "Success" else "Failure", - *amount?.asProperties()?.toList()?.toTypedArray() ?: emptyArray(), - *error.asProperty() - ) - } - - override fun transfer( - event: Analytics.Transfer, - fiat: Fiat?, - successful: Boolean, - error: Throwable? - ) { - track( - event.toAnalyticsEvent(), - "State" to if (successful) "Success" else "Failure", - *fiat?.asProperties()?.toList()?.toTypedArray() ?: emptyArray(), - *error.asProperty() - ) - } - - override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) { - track(AnalyticsEvent.PaidForAccount(price, currency, owner)) - } - - override fun openOnramp(source: Analytics.OnrampSource) { - val event = when (source) { - Analytics.OnrampSource.Settings -> AnalyticsEvent.OnRampOpenEvent.Settings - Analytics.OnrampSource.Balance -> AnalyticsEvent.OnRampOpenEvent.Balance - Analytics.OnrampSource.Give -> AnalyticsEvent.OnRampOpenEvent.Give - } - track(event) - } - - override fun onrampVerification(step: Analytics.OnrampVerificationStep) { - val event = when (step) { - Analytics.OnrampVerificationStep.ShowInfo -> AnalyticsEvent.OnRampVerificationEvent.ShowInfo - Analytics.OnrampVerificationStep.EnterPhone -> AnalyticsEvent.OnRampVerificationEvent.EnterPhone - Analytics.OnrampVerificationStep.ConfirmPhone -> AnalyticsEvent.OnRampVerificationEvent.ConfirmPhone - Analytics.OnrampVerificationStep.EnterEmail -> AnalyticsEvent.OnRampVerificationEvent.EnterEmail - Analytics.OnrampVerificationStep.ConfirmEmail -> AnalyticsEvent.OnRampVerificationEvent.ConfirmEmail - } - track(event) - } - - override fun onrampPurchase( - step: Analytics.OnrampPurchaseStep, - amount: Fiat? - ) { - val event = when (step) { - Analytics.OnrampPurchaseStep.PresetSelected -> AnalyticsEvent.OnRampPurchaseEvent.PresetSelected - Analytics.OnrampPurchaseStep.EnterCustomAmount -> AnalyticsEvent.OnRampPurchaseEvent.EnterCustomAmount - Analytics.OnrampPurchaseStep.InvokePayment -> amount?.let { - AnalyticsEvent.OnRampPurchaseEvent.InvokePayment( - it - ) - } - - Analytics.OnrampPurchaseStep.InvokePaymentCustom -> amount?.let { - AnalyticsEvent.OnRampPurchaseEvent.InvokePaymentCustom( - it - ) - } - - Analytics.OnrampPurchaseStep.Completed -> amount?.let { - AnalyticsEvent.OnRampPurchaseEvent.Completed( - it - ) - } - } ?: return - track(event) - } - - override fun addMoneyOpened(source: Analytics.AddMoneySource) { - track(AnalyticsEvent.AddMoneyEvent.Opened(source)) - } - - override fun addMoneyMethodSelected(method: Analytics.AddMoneyMethod) { - track(AnalyticsEvent.AddMoneyEvent.MethodSelected(method)) - } - - override fun addMoneyAmountConfirmed(method: Analytics.AddMoneyMethod, amount: Fiat) { - track(AnalyticsEvent.AddMoneyEvent.AmountConfirmed(method, amount)) - } - - override fun addMoneyPaymentInvoked(method: Analytics.AddMoneyMethod, amount: Fiat) { - track(AnalyticsEvent.AddMoneyEvent.PaymentInvoked(method, amount)) - } - - override fun addMoneyAddressCopied(mint: Mint) { - track(AnalyticsEvent.AddMoneyEvent.AddressCopied(mint)) - } - - override fun addMoney( - method: Analytics.AddMoneyMethod, - amount: Fiat?, - successful: Boolean, - error: Throwable? - ) { - track( - AnalyticsEvent.AddMoneyEvent.Terminal(method), - "State" to if (successful) "Success" else "Failure", - *amount?.asProperties()?.toList()?.toTypedArray() ?: emptyArray(), - *error.asProperty() - ) - } - - override fun connectWallet(provider: OnRampProvider.UsesDeeplinks) { - track(AnalyticsEvent.WalletConnect(provider)) - } - - override fun amountSelectedForWalletTransfer( - provider: OnRampProvider.UsesDeeplinks, - amount: Fiat - ) { - track(AnalyticsEvent.WalletRequestAmount(provider, amount)) - } - - override fun transactionSubmittedToWallet(provider: OnRampProvider.UsesDeeplinks) { - track(AnalyticsEvent.WalletSubmitTransaction(provider)) - } - - override fun walletTransactionFailed(provider: OnRampProvider.UsesDeeplinks) { - track(AnalyticsEvent.WalletTransactionFailed(provider)) - } - - override fun walletTransactionCancelled(provider: OnRampProvider.UsesDeeplinks) { - track(AnalyticsEvent.WalletTransactionCancelled(provider)) - } - - override fun openTokenInfo(source: Analytics.TokenInfoSource, mint: Mint) { - val event = when (source) { - Analytics.TokenInfoSource.Deeplink -> AnalyticsEvent.OpenTokenInfoEvent.Deeplink(mint) - Analytics.TokenInfoSource.Wallet -> AnalyticsEvent.OpenTokenInfoEvent.Wallet(mint) - Analytics.TokenInfoSource.Give -> AnalyticsEvent.OpenTokenInfoEvent.Give(mint) - } - track(event) - } - - override fun buy( - method: Analytics.PurchaseMethod, - mint: Mint, - amount: Fiat, - error: Throwable? - ) { - val event = when (method) { - Analytics.PurchaseMethod.Reserves -> AnalyticsEvent.TokenTransactionEvent.Purchase.Reserves( - mint, - amount, - error - ) - - Analytics.PurchaseMethod.Phantom -> AnalyticsEvent.TokenTransactionEvent.Purchase.Phantom( - mint, - amount, - error - ) - - Analytics.PurchaseMethod.Coinbase -> AnalyticsEvent.TokenTransactionEvent.Purchase.Coinbase( - mint, - amount, - error + if (BuildConfig.DEBUG) { + val propsString = properties.entries.joinToString { "${it.key} => ${it.value}" } + trace( + buildString { + append("debug track ${event.name}") + if (propsString.isNotEmpty()) append(", $propsString") + }, + type = TraceType.Silent ) + return } - track(event) - } - - override fun sell(mint: Mint, amount: Fiat, feeAmount: Fiat, error: Throwable?) { - track(AnalyticsEvent.TokenTransactionEvent.Sell(mint, amount, feeAmount, error)) - } - - override fun messageSentInChat(type: ChatType, error: Throwable?) { - track(AnalyticsEvent.ChatEvent.SentMessage(chatType = type, error = error)) - } - - override fun tipCardScanned() { - track(AnalyticsEvent.TipCardEvent.Scanned) - } - - override fun galleryImagePicked() { - track(AnalyticsEvent.GalleryScanEvent.ImagePicked) - } - - override fun galleryScanSucceeded(tier: Int, zoom: Float, timeMillis: Long) { - track(AnalyticsEvent.GalleryScanEvent.Succeeded(tier, zoom, timeMillis)) - } - - override fun galleryScanFailed(timeMillis: Long, exhausted: Boolean) { - track(AnalyticsEvent.GalleryScanEvent.Failed(timeMillis, exhausted)) - } - - override fun tipCardPresented() { - track(AnalyticsEvent.TipCardEvent.Presented) - } - - override fun deeplinkOpened(url: String) { - track(AnalyticsEvent.DeeplinkEvent.Open(url)) - } - - override fun deeplinkParsed(type: DeeplinkType?, url: String) { - track(AnalyticsEvent.DeeplinkEvent.Parse(type, url)) - } - - override fun deeplinkRouted(type: DeeplinkType, error: Throwable?) { - track(AnalyticsEvent.DeeplinkEvent.Routed(type, error)) - } - - override fun displayedErrorModal(title: String, message: String, screen: String?, callSite: String?) { - track(AnalyticsEvent.ErrorModalDisplayed(title, message, screen, callSite)) - } - - override fun displayNameSubmitted(source: DisplayNameSource, hadPreviousName: Boolean) { - val event = if (hadPreviousName) { - AnalyticsEvent.DisplayNameEvent.Updated(source) - } else { - AnalyticsEvent.DisplayNameEvent.Set(source) - } - track(event) - } - - override fun incrementReceivedCounter(counter: Analytics.ReceivedCounter, amount: Double) { - increment(counter.propertyValue, amount) - } - override fun tipReceived(chatType: ChatType, amount: Fiat, mint: Mint) { - track(AnalyticsEvent.ChatEvent.TipReceived(chatType, amount, mint)) + mixpanelAPI.track(event.name, JSONObject(properties)) } - override fun messageReceived(chatType: ChatType) { - track(AnalyticsEvent.ChatEvent.MessageReceived(chatType)) + override fun increment(counter: PeopleCounter, amount: Double) { + increment(counter.key, amount) } // region Internal - private fun track(event: AnalyticsEvent, vararg extra: Pair) { - val properties = (event.toProperties().toList() + extra.toList()).toTypedArray() - track(event.name, *properties) - } - private fun increment(property: String, amount: Double) { if (BuildConfig.DEBUG) { trace("debug increment $property by $amount", type = TraceType.Silent) @@ -319,28 +49,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( mixpanelAPI.people.increment(property, amount) } - private fun track(name: String, vararg properties: Pair) { - val resolved = properties.toList().withTokenSymbols(tokenSymbolResolver) - - if (BuildConfig.DEBUG) { - val propsString = resolved.joinToString { "${it.first} => ${it.second}" } - trace( - buildString { - append("debug track $name") - if (propsString.isNotEmpty()) append(", $propsString") - }, - type = TraceType.Silent - ) - return - } - - val jsonObject = JSONObject() - resolved.forEach { jsonObject.put(it.first, it.second) } - mixpanelAPI.track(name, jsonObject) - } - - private fun Throwable?.asProperty(): Array> = - this?.let { arrayOf("Error" to it.message.orEmpty()) } ?: emptyArray() // endregion } @@ -351,19 +59,25 @@ private val MINT_PROPERTIES = mapOf( ) /** - * Returns [properties] with a ticker added beside every mint the [resolver] knows. - * - * An unresolvable mint adds nothing — the property must be absent rather than - * empty, so a failed cache lookup is distinguishable from a token with no symbol. + * Converts [this] event's properties to the values Mixpanel's JSON takes, with a ticker + * added beside every mint the [resolver] knows. */ -internal fun List>.withTokenSymbols( +internal fun AnalyticsEvent.toMixpanelProperties( resolver: TokenSymbolResolver, -): List> { - val present = map { it.first }.toSet() - val symbols = mapNotNull { (key, value) -> - val symbolKey = MINT_PROPERTIES[key] ?: return@mapNotNull null - if (symbolKey in present) return@mapNotNull null - resolver.symbolFor(value)?.let { symbolKey to it } +): Map = buildMap { + properties.forEach { (key, value) -> + put( + key, + when (value) { + is PropertyValue.Text -> value.value + is PropertyValue.Number -> value.value + is PropertyValue.Flag -> value.value + } + ) + } + MINT_PROPERTIES.forEach { (mintKey, symbolKey) -> + if (symbolKey in this) return@forEach + val mint = this[mintKey] as? String ?: return@forEach + resolver.symbolFor(mint)?.let { put(symbolKey, it) } } - return this + symbols } diff --git a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt new file mode 100644 index 0000000000..6066743337 --- /dev/null +++ b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt @@ -0,0 +1,92 @@ +package com.flipcash.app.analytics + +import com.flipcash.analytics.Amount +import com.flipcash.app.core.chat.ChatIdentifier +import com.flipcash.app.core.navigation.DeeplinkType +import com.flipcash.services.models.chat.ChatId +import com.getcode.opencode.model.core.ID +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.Mint +import org.junit.Assert.assertEquals +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ConversionsTest { + + private val mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v") + + @Test + fun `a Fiat keeps quarks of ten million or more as a whole number`() { + val fiat = Fiat(quarks = 25_000_000L, currencyCode = CurrencyCode.USD) + + val amount = fiat.analytics + + assertEquals( + Amount(fiat = fiat.decimalValue, currency = "USD", usdc = fiat.decimalValue, quarks = 25_000_000L), + amount, + ) + } + + @Test + fun `a LocalFiat carries the native amount, the token amount, the rate and the mint`() { + val token = Fiat(quarks = 25_000_000L, currencyCode = CurrencyCode.USD) + val native = Fiat(quarks = 34_000_000L, currencyCode = CurrencyCode.CAD) + val localFiat = LocalFiat( + underlyingTokenAmount = token, + nativeAmount = native, + rate = Rate(fx = 1.36, currency = CurrencyCode.CAD), + mint = mint, + ) + + val amount = localFiat.analytics + + assertEquals( + Amount( + fiat = native.decimalValue, + currency = "CAD", + usdc = token.decimalValue, + quarks = 25_000_000L, + exchangeRate = 1.36, + mint = "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v", + ), + amount, + ) + } + + @Test + fun `every deeplink type maps to its source name`() { + val id: ID = ByteArray(16).toList() + val chatId = ChatId(ByteArray(16)) + val types = listOf( + DeeplinkType.Login(entropy = "e"), + DeeplinkType.CashLink(entropy = "e"), + DeeplinkType.TokenInfo(mint = mint), + DeeplinkType.TipChat(identifier = ChatIdentifier.ByChatId(chatId)), + DeeplinkType.GroupChatInvite(chatId = chatId), + DeeplinkType.Tipcard(userId = id), + DeeplinkType.TipcardByUsername(username = "someone"), + DeeplinkType.EmailVerification(email = "a@b.c", code = "123"), + ) + + assertEquals( + listOf( + "Login", "CashLink", "TokenInfo", "TipChat", + "GroupChatInvite", "Tipcard", "TipcardByUsername", "EmailVerification", + ), + types.map { it.analytics }, + ) + } + + @Test + fun `withoutQueryOrFragment drops the query and the fragment`() { + assertEquals( + "https://app.flipcash.com/c/abc", + "https://app.flipcash.com/c/abc?a=b#c".withoutQueryOrFragment(), + ) + } +} diff --git a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/MixpanelTrackTest.kt b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/MixpanelTrackTest.kt new file mode 100644 index 0000000000..d1f3a0f7ad --- /dev/null +++ b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/MixpanelTrackTest.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.analytics + +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.PropertyValue +import com.flipcash.app.analytics.internal.toMixpanelProperties +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Test + +class MixpanelTrackTest { + + private val resolver = TokenSymbolResolver { mint -> + when (mint) { + "MintA" -> "AAA" + "MintB" -> "BBB" + else -> null + } + } + + @Test + fun `maps each property type to its JSON type`() { + val event = AnalyticsEvent( + name = "Test", + properties = mapOf( + "Text" to PropertyValue.Text("hello"), + "Number" to PropertyValue.Number(25_000_000.0), + "Flag" to PropertyValue.Flag(true), + ), + ) + + val result = event.toMixpanelProperties(resolver) + + assertEquals(mapOf("Text" to "hello", "Number" to 25_000_000.0, "Flag" to true), result) + } + + @Test + fun `adds Token Symbol beside Mint`() { + val event = AnalyticsEvent("Test", mapOf("Mint" to PropertyValue.Text("MintA"))) + + val result = event.toMixpanelProperties(resolver) + + assertEquals(mapOf("Mint" to "MintA", "Token Symbol" to "AAA"), result) + } + + @Test + fun `adds Payment Token Symbol beside Payment Mint`() { + val event = AnalyticsEvent( + "Test", + mapOf("Mint" to PropertyValue.Text("MintA"), "Payment Mint" to PropertyValue.Text("MintB")), + ) + + val result = event.toMixpanelProperties(resolver) + + assertEquals("AAA", result["Token Symbol"]) + assertEquals("BBB", result["Payment Token Symbol"]) + } + + @Test + fun `adds nothing when the mint is unknown`() { + val event = AnalyticsEvent("Test", mapOf("Mint" to PropertyValue.Text("MintZ"))) + + val result = event.toMixpanelProperties(resolver) + + assertFalse(result.containsKey("Token Symbol")) + } + + @Test + fun `does not overwrite a symbol the event already supplied`() { + val event = AnalyticsEvent( + "Test", + mapOf("Mint" to PropertyValue.Text("MintA"), "Token Symbol" to PropertyValue.Text("EXPLICIT")), + ) + + val result = event.toMixpanelProperties(resolver) + + assertEquals("EXPLICIT", result["Token Symbol"]) + } +} diff --git a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/TokenSymbolPropertyTest.kt b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/TokenSymbolPropertyTest.kt deleted file mode 100644 index a4092d0eb0..0000000000 --- a/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/TokenSymbolPropertyTest.kt +++ /dev/null @@ -1,59 +0,0 @@ -package com.flipcash.app.analytics - -import com.flipcash.app.analytics.internal.withTokenSymbols -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Test - -class TokenSymbolPropertyTest { - - private val resolver = TokenSymbolResolver { mint -> - when (mint) { - "MintA" -> "AAA" - "MintB" -> "BBB" - else -> null - } - } - - @Test - fun `adds Token Symbol beside Mint`() { - val result = listOf("Mint" to "MintA").withTokenSymbols(resolver).toMap() - assertEquals("AAA", result["Token Symbol"]) - assertEquals("MintA", result["Mint"]) - } - - @Test - fun `adds Payment Token Symbol beside Payment Mint`() { - val result = listOf("Payment Mint" to "MintB").withTokenSymbols(resolver).toMap() - assertEquals("BBB", result["Payment Token Symbol"]) - } - - @Test - fun `omits the property entirely when the mint is unknown`() { - val result = listOf("Mint" to "MintZ").withTokenSymbols(resolver).toMap() - // Absent, not empty — a failed lookup must be distinguishable from a - // token that genuinely has no symbol. - assertFalse(result.containsKey("Token Symbol")) - } - - @Test - fun `leaves properties without a mint untouched`() { - val input = listOf("Chat Type" to "Tip", "Fiat" to "5.0") - assertEquals(input, input.withTokenSymbols(resolver)) - } - - @Test - fun `does not overwrite a symbol the event already supplied`() { - val result = listOf("Mint" to "MintA", "Token Symbol" to "EXPLICIT") - .withTokenSymbols(resolver).toMap() - assertEquals("EXPLICIT", result["Token Symbol"]) - } - - @Test - fun `resolves both mints when an event carries both`() { - val result = listOf("Mint" to "MintA", "Payment Mint" to "MintB") - .withTokenSymbols(resolver).toMap() - assertEquals("AAA", result["Token Symbol"]) - assertEquals("BBB", result["Payment Token Symbol"]) - } -} diff --git a/apps/flipcash/shared/analytics/src/testFixtures/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt b/apps/flipcash/shared/analytics/src/testFixtures/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt new file mode 100644 index 0000000000..3a11fa9139 --- /dev/null +++ b/apps/flipcash/shared/analytics/src/testFixtures/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt @@ -0,0 +1,12 @@ +package com.flipcash.app.analytics + +import com.flipcash.analytics.AnalyticsEvent +import com.flipcash.analytics.PeopleCounter + +/** Records what was sent, for tests. Not thread-safe; tests drive it from one dispatcher. */ +class RecordingAnalytics : FlipcashAnalytics { + val events = mutableListOf() + val increments = mutableListOf>() + override fun track(event: AnalyticsEvent) { events += event } + override fun increment(counter: PeopleCounter, amount: Double) { increments += counter to amount } +} diff --git a/apps/flipcash/shared/chat/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index b593dfc6df..a5c83155d4 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { // so this module's public surface names a type from it. api(project(":apps:flipcash:shared:tokens")) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":services:flipcash")) implementation(project(":libs:network:connectivity:public")) implementation(libs.androidx.lifecycle.process) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index e661850687..6e99eef415 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt @@ -1,7 +1,7 @@ package com.flipcash.shared.chat.internal.delegates -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.PeopleCounter +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource @@ -85,7 +85,7 @@ class EventStreamDelegate @Inject constructor( private val tokenCoordinator: TokenCoordinator, private val userManager: UserManager, private val stateHolder: ChatStateHolder, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val exchange: Exchange, ) : EventStreamOperations { @@ -137,14 +137,14 @@ class EventStreamDelegate @Inject constructor( * Received` is a total, not a non-tip remainder. */ private fun countReceipt(msg: ChatMessage) { - analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Messages) + analytics.increment(PeopleCounter.MESSAGES) val tip = msg.content .filterIsInstance() .firstOrNull { it.action == MessageContent.Cash.Action.TIPPED } ?: return - analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Tips) + analytics.increment(PeopleCounter.TIPS) // Chat cash arrives in the SENDER's native currency. With no cached rate // we count the tip but skip its value: an understated total is @@ -153,7 +153,7 @@ class EventStreamDelegate @Inject constructor( ?.let { tip.amount.convertingTo(it) } ?: return - analytics.incrementReceivedCounter(Analytics.ReceivedCounter.TipsValue, usd.decimalValue) + analytics.increment(PeopleCounter.TIPS_VALUE, usd.decimalValue) } internal fun initialize(scope: CoroutineScope) { diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt index 19978ddc2e..161726cdc1 100644 --- a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt +++ b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/MessagingDelegate.kt @@ -8,7 +8,9 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.events.ChatEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource @@ -78,7 +80,7 @@ class MessagingDelegate @Inject constructor( private val notificationManager: NotificationManagerCompat, private val userManager: UserManager, private val stateHolder: ChatStateHolder, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val senderResolver: SenderResolver, ) : MessagingOperations { @@ -466,9 +468,10 @@ class MessagingDelegate @Inject constructor( .firstOrNull { it.action == MessageContent.Cash.Action.TIPPED } if (tip != null) { - analytics.tipReceived(chatType, tip.amount, tip.mint) + val amount = tip.amount.analytics.copy(mint = tip.mint.analytics) + analytics.track(ChatEvents.tipReceived(chatType.analytics, amount)) } else { - analytics.messageReceived(chatType) + analytics.track(ChatEvents.messageReceived(chatType.analytics)) } } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt index f89a2c6ba0..861da0f4d5 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt @@ -1,7 +1,7 @@ package com.flipcash.shared.chat -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.PeopleCounter +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource @@ -32,7 +32,6 @@ import com.getcode.opencode.model.financial.Rate import com.getcode.solana.keys.Mint import com.getcode.utils.network.NetworkConnectivityListener import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -47,6 +46,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant @@ -61,7 +61,7 @@ class ReceivedCounterTest { private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) - private lateinit var analytics: FlipcashAnalyticsService + private lateinit var analytics: RecordingAnalytics private lateinit var metadataDataSource: ChatMetadataDataSource private lateinit var exchange: Exchange private lateinit var coordinator: RealChatCoordinator @@ -72,7 +72,7 @@ class ReceivedCounterTest { @Before fun setUp() { - analytics = mockk(relaxed = true) + analytics = RecordingAnalytics() exchange = mockk(relaxed = true) every { exchange.rateToUsd(CurrencyCode.USD) } returns Rate(1.0, CurrencyCode.USD) every { exchange.rateToUsd(CurrencyCode.CAD) } returns Rate(0.5, CurrencyCode.USD) @@ -217,9 +217,9 @@ class ReceivedCounterTest { coordinator.onUserLoggedIn(mockk(relaxed = true)) deliver(tipMessage(messageId = 1L, senderId = otherId)) - coVerify(exactly = 1) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Tips, 1.0) } - coVerify(exactly = 1) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Messages, 1.0) } - coVerify(exactly = 1) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.TipsValue, 5.0) } + assertEquals(1, analytics.increments.count { it == PeopleCounter.TIPS to 1.0 }) + assertEquals(1, analytics.increments.count { it == PeopleCounter.MESSAGES to 1.0 }) + assertEquals(1, analytics.increments.count { it == PeopleCounter.TIPS_VALUE to 5.0 }) } } @@ -229,8 +229,8 @@ class ReceivedCounterTest { coordinator.onUserLoggedIn(mockk(relaxed = true)) deliver(textMessage(messageId = 1L, senderId = otherId)) - coVerify(exactly = 1) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Messages, 1.0) } - coVerify(exactly = 0) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Tips, any()) } + assertEquals(1, analytics.increments.count { it == PeopleCounter.MESSAGES to 1.0 }) + assertEquals(0, analytics.increments.count { it.first == PeopleCounter.TIPS }) } } @@ -240,7 +240,7 @@ class ReceivedCounterTest { coordinator.onUserLoggedIn(mockk(relaxed = true)) deliver(tipMessage(messageId = 1L, senderId = selfId)) - coVerify(exactly = 0) { analytics.incrementReceivedCounter(any(), any()) } + assertEquals(emptyList(), analytics.increments) } } @@ -253,7 +253,7 @@ class ReceivedCounterTest { deliver(msg) deliver(msg) // gap fill / reconnect replays the same message - coVerify(exactly = 1) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Tips, 1.0) } + assertEquals(1, analytics.increments.count { it == PeopleCounter.TIPS to 1.0 }) } } @@ -264,7 +264,7 @@ class ReceivedCounterTest { coordinator.onUserLoggedIn(mockk(relaxed = true)) deliver(textMessage(messageId = 10L, senderId = otherId)) - coVerify(exactly = 0) { analytics.incrementReceivedCounter(any(), any()) } + assertEquals(emptyList(), analytics.increments) } } @@ -275,7 +275,7 @@ class ReceivedCounterTest { val cad = Fiat(fiat = 10.0, currencyCode = CurrencyCode.CAD) deliver(tipMessage(messageId = 1L, senderId = otherId, amount = cad)) - coVerify(exactly = 1) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.TipsValue, 5.0) } + assertEquals(1, analytics.increments.count { it == PeopleCounter.TIPS_VALUE to 5.0 }) } } @@ -287,8 +287,8 @@ class ReceivedCounterTest { val eur = Fiat(fiat = 10.0, currencyCode = CurrencyCode.EUR) deliver(tipMessage(messageId = 1L, senderId = otherId, amount = eur)) - coVerify(exactly = 1) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Tips, 1.0) } - coVerify(exactly = 0) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.TipsValue, any()) } + assertEquals(1, analytics.increments.count { it == PeopleCounter.TIPS to 1.0 }) + assertEquals(0, analytics.increments.count { it.first == PeopleCounter.TIPS_VALUE }) } } diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt index fbfccf06c6..ceb308cd6d 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt @@ -1,6 +1,9 @@ package com.flipcash.shared.chat -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.ChatType as AnalyticsChatType +import com.flipcash.analytics.events.ChatEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.dispatchers.TestDispatchers import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource @@ -32,7 +35,6 @@ import com.getcode.opencode.model.financial.Rate import com.getcode.solana.keys.Mint import com.getcode.utils.network.NetworkConnectivityListener import io.mockk.coEvery -import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -47,6 +49,7 @@ import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Instant @@ -61,7 +64,7 @@ class ReceivedEventTest { private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) - private lateinit var analytics: FlipcashAnalyticsService + private lateinit var analytics: RecordingAnalytics private lateinit var metadataDataSource: ChatMetadataDataSource private lateinit var memberDataSource: ChatMemberDataSource private lateinit var messageDataSource: ChatMessageDataSource @@ -74,7 +77,7 @@ class ReceivedEventTest { @Before fun setUp() { - analytics = mockk(relaxed = true) + analytics = RecordingAnalytics() exchange = mockk(relaxed = true) every { exchange.rateToUsd(CurrencyCode.USD) } returns Rate(1.0, CurrencyCode.USD) every { exchange.rateToUsd(CurrencyCode.CAD) } returns Rate(0.5, CurrencyCode.USD) @@ -227,7 +230,10 @@ class ReceivedEventTest { coordinator.advanceReadPointer(chatId, 3L) - coVerify(exactly = 3) { analytics.messageReceived(ChatType.TIP_DM) } + assertEquals( + List(3) { ChatEvents.messageReceived(AnalyticsChatType.TIP) }, + analytics.events, + ) } @Test @@ -240,8 +246,12 @@ class ReceivedEventTest { coordinator.advanceReadPointer(chatId, 1L) - coVerify(exactly = 1) { analytics.tipReceived(ChatType.TIP_DM, any(), any()) } - coVerify(exactly = 0) { analytics.messageReceived(any()) } + val expectedAmount = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD).analytics + .copy(mint = mint.analytics) + assertEquals( + listOf(ChatEvents.tipReceived(AnalyticsChatType.TIP, expectedAmount)), + analytics.events, + ) } @Test @@ -250,8 +260,7 @@ class ReceivedEventTest { coordinator.advanceReadPointer(chatId, 5L) - coVerify(exactly = 0) { analytics.messageReceived(any()) } - coVerify(exactly = 0) { analytics.tipReceived(any(), any(), any()) } + assertEquals(emptyList(), analytics.events) } @Test @@ -261,7 +270,7 @@ class ReceivedEventTest { coordinator.advanceReadPointer(chatId, 3L) - coVerify(exactly = 0) { analytics.messageReceived(any()) } + assertEquals(emptyList(), analytics.events) } @Test @@ -275,6 +284,6 @@ class ReceivedEventTest { coordinator.advanceReadPointer(chatId, 2L) - coVerify(exactly = 0) { analytics.messageReceived(any()) } + assertEquals(emptyList(), analytics.events) } } diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt index d592d18072..a0f29ef872 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -16,8 +16,8 @@ import androidx.lifecycle.LifecycleOwner import android.os.Handler import android.os.Looper import androidx.lifecycle.ProcessLifecycleOwner -import com.flipcash.app.analytics.Action -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.events.AccountEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.contacts.device.DeviceContactLookup import com.flipcash.app.contacts.device.PickedContactData import com.flipcash.app.contacts.device.ScopeAwareContactReader @@ -83,7 +83,7 @@ class ContactCoordinator @Inject constructor( private val contactDataSource: ContactDataSource, private val userManager: UserManager, private val dispatchers: DispatcherProvider, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, ) : SessionListener, DefaultLifecycleObserver { companion object { @@ -297,7 +297,7 @@ class ContactCoordinator @Inject constructor( if (phone != null) { contactVerificationController.linkForPayment(ContactMethod.Phone(phone)) .onSuccess { - analytics.action(Action.LinkedPhoneNumber) + analytics.track(AccountEvents.linkedPhoneNumber()) contactPrefs.edit { it[KEY_LINKED_FOR_PAYMENT] = true } } } diff --git a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt index be9ed7f5a7..4611a96b28 100644 --- a/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt +++ b/apps/flipcash/shared/funding/src/main/kotlin/com/flipcash/app/funding/internal/InternalPurchaseMethodController.kt @@ -1,7 +1,8 @@ package com.flipcash.app.funding.internal -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.AddMoneyMethod +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.core.tokens.SwapPurpose @@ -53,7 +54,7 @@ class InternalPurchaseMethodController @Inject constructor( exchange: Exchange, private val resources: ResourceHelper, private val userManager: UserManager, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, ) : PurchaseMethodController { private val scope = CoroutineScope(SupervisorJob()) @@ -141,13 +142,13 @@ class InternalPurchaseMethodController @Inject constructor( when (result) { PurchaseMethod.CoinbaseOnRamp -> - analytics.addMoneyMethodSelected(Analytics.AddMoneyMethod.Coinbase) + analytics.track(AddMoneyEvents.methodSelected(AddMoneyMethod.COINBASE)) PurchaseMethod.PhantomWallet -> - analytics.addMoneyMethodSelected(Analytics.AddMoneyMethod.Phantom) + analytics.track(AddMoneyEvents.methodSelected(AddMoneyMethod.PHANTOM)) PurchaseMethod.OtherWallet -> - analytics.addMoneyMethodSelected(Analytics.AddMoneyMethod.OtherWallet) + analytics.track(AddMoneyEvents.methodSelected(AddMoneyMethod.OTHER_WALLET)) is PurchaseMethod.CashReserves -> - analytics.addMoneyMethodSelected(Analytics.AddMoneyMethod.Reserves) + analytics.track(AddMoneyEvents.methodSelected(AddMoneyMethod.RESERVES)) null -> Unit } @@ -194,4 +195,4 @@ class InternalPurchaseMethodController @Inject constructor( null -> null } } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/inject/BillingModule.kt b/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/inject/BillingModule.kt index 7ed1ad4333..7688bebbec 100644 --- a/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/inject/BillingModule.kt +++ b/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/inject/BillingModule.kt @@ -1,7 +1,7 @@ package com.flipcash.app.billing.inject import android.content.Context -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.billing.BillingClient import com.flipcash.app.billing.internal.GooglePlayBillingClient import com.flipcash.libs.coroutines.DispatcherProvider @@ -23,7 +23,7 @@ object BillingModule { @ApplicationContext context: Context, purchases: PurchaseController, userManager: UserManager, - analytics: FlipcashAnalyticsService, + analytics: FlipcashAnalytics, dispatchers: DispatcherProvider, ): BillingClient = GooglePlayBillingClient(context, userManager, purchases, analytics, dispatchers) -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt b/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt index ccccc50081..c14568637c 100644 --- a/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt +++ b/apps/flipcash/shared/google-play-billing/src/main/kotlin/com/flipcash/app/billing/internal/GooglePlayBillingClient.kt @@ -21,7 +21,9 @@ import com.android.billingclient.api.QueryProductDetailsParams import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.acknowledgePurchase import com.android.billingclient.api.consumePurchase -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.events.AccountEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.getcode.utils.getPublicKeyBase58 import com.flipcash.app.billing.BillingClient import com.flipcash.app.billing.BillingClientConnection import com.flipcash.app.billing.BillingClientState @@ -60,7 +62,7 @@ internal class GooglePlayBillingClient( @ApplicationContext context: Context, private val userManager: UserManager, private val purchases: PurchaseController, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val dispatchers: DispatcherProvider, ) : BillingClient, PurchasesUpdatedListener { @@ -262,10 +264,12 @@ internal class GooglePlayBillingClient( currency = purchasePrice.currency ) ).onSuccess { - analytics.paidForAccount( - price = purchasePrice.amount, - currency = purchasePrice.currency, - owner = owner, + analytics.track( + AccountEvents.createAccountPayment( + price = purchasePrice.amount, + currency = purchasePrice.currency.name, + owner = owner.getPublicKeyBase58(), + ) ) acknowledgeOrConsume(item) }.onFailure { @@ -512,4 +516,4 @@ internal class GooglePlayBillingClient( type = TraceType.Process, metadata = metadata ) -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt index ef36f92c59..f13a6ed0ab 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt @@ -3,12 +3,13 @@ package com.flipcash.app.permissions import androidx.activity.compose.BackHandler import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import com.flipcash.app.analytics.Action -import com.flipcash.app.analytics.Button +import com.flipcash.analytics.Button +import com.flipcash.analytics.events.AccountEvents +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent import com.flipcash.app.permissions.internal.notifications.NotificationScreenContent -import com.getcode.libs.analytics.LocalAnalytics import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.core.NavOptions import com.getcode.util.permissions.PermissionResult @@ -17,13 +18,13 @@ import com.getcode.util.permissions.rememberNotificationPermission @Composable fun NotificationPermissionScreen(fromOnboarding: Boolean = false) { val navigator = LocalCodeNavigator.current - val analytics = LocalAnalytics.current + val analytics = rememberAnalytics() val permissionState = rememberNotificationPermission { result -> when (result) { PermissionResult.Granted -> { - analytics.action(Button.AllowPush) - if (fromOnboarding) analytics.action(Action.CompletedOnboarding) + analytics.track(ButtonEvents.tapped(Button.ALLOW_PUSH)) + if (fromOnboarding) analytics.track(AccountEvents.completeOnboarding()) navigator.navigate( route = AppRoute.Tabs.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) @@ -59,7 +60,7 @@ fun NotificationPermissionScreen(fromOnboarding: Boolean = false) { NotificationScreenContent( permissionState = permissionState, onSkip = { - analytics.action(Button.SkipPush) + analytics.track(ButtonEvents.tapped(Button.SKIP_PUSH)) navigator.navigate( route = AppRoute.Tabs.Scanner, options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt index 2b3bc35053..c888ca0198 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt @@ -17,7 +17,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewWrapper -import com.flipcash.app.analytics.StubFlipcashAnalytics +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.permissions.ContactAccessHandle import com.flipcash.app.permissions.asContactAccessHandle import com.flipcash.app.permissions.internal.contacts.components.AnimatedContactListPreview @@ -25,7 +25,7 @@ import com.flipcash.app.permissions.internal.contacts.components.ContactPermissi import com.flipcash.app.theme.FlipcashPreview import com.flipcash.app.theme.FlipcashThemeWrapper import com.flipcash.shared.permissions.R -import com.getcode.libs.analytics.LocalAnalytics +import com.flipcash.app.analytics.LocalAnalytics import com.getcode.theme.CodeTheme import com.getcode.ui.theme.CodeScaffold import com.getcode.util.permissions.ProvideTestPermissions @@ -167,7 +167,7 @@ private fun SimplifiedContactScreenContent( @Preview @PreviewWrapper(FlipcashThemeWrapper::class) private fun PreviewContactPermissionScreen() { - CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) { + CompositionLocalProvider(LocalAnalytics provides FlipcashAnalytics.None) { ProvideTestPermissions(granted = emptySet()) { val state = rememberContactPermission() ContactScreenContent(state.asContactAccessHandle(), onSkip = { }) @@ -179,10 +179,10 @@ private fun PreviewContactPermissionScreen() { @Preview @PreviewWrapper(FlipcashThemeWrapper::class) private fun PreviewSimplifiedContactPermissionScreen() { - CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) { + CompositionLocalProvider(LocalAnalytics provides FlipcashAnalytics.None) { ProvideTestPermissions(granted = emptySet()) { val state = rememberContactPermission() ContactScreenContent(state.asContactAccessHandle(), simplified = true) } } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt index 1aba9498ee..d7a7acf07e 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt @@ -13,12 +13,12 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.flipcash.app.analytics.StubFlipcashAnalytics +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.permissions.internal.notifications.components.AnimatedNotificationPreview import com.flipcash.app.permissions.internal.notifications.components.NotificationPermissionBottomBar import com.flipcash.app.theme.FlipcashPreview import com.flipcash.shared.permissions.R -import com.getcode.libs.analytics.LocalAnalytics +import com.flipcash.app.analytics.LocalAnalytics import com.getcode.theme.CodeTheme import com.getcode.ui.theme.CodeScaffold import com.getcode.util.permissions.PermissionHandle @@ -70,11 +70,11 @@ fun NotificationScreenContent( @Preview private fun PreviewNotificationPermissionScreen() { FlipcashPreview(showBackground = true) { - CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) { + CompositionLocalProvider(LocalAnalytics provides FlipcashAnalytics.None) { ProvideTestPermissions(granted = emptySet()) { val state = rememberNotificationPermission() NotificationScreenContent(state) { } } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt index 3ad3708a81..9c579e629c 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt @@ -17,14 +17,15 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview -import com.flipcash.app.analytics.Button -import com.flipcash.app.analytics.StubFlipcashAnalytics +import com.flipcash.analytics.Button +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.android.extensions.launchAppSettings import com.flipcash.app.permissions.internal.notifications.components.AnimatedSwitchPreview import com.flipcash.app.theme.FlipcashPreview import com.flipcash.shared.permissions.R -import com.getcode.libs.analytics.LocalAnalytics +import com.flipcash.app.analytics.LocalAnalytics import com.getcode.theme.CodeTheme import com.getcode.ui.theme.ButtonState import com.getcode.ui.theme.CodeButton @@ -61,7 +62,7 @@ fun NotificationRationalePermissionContent( .padding(bottom = CodeTheme.dimens.grid.x2) .padding(horizontal = CodeTheme.dimens.inset), onClick = { - analytics.action(Button.SkipPush) + analytics.track(ButtonEvents.tapped(Button.SKIP_PUSH)) onComplete() }, text = if (permanentlyDenied) { @@ -132,8 +133,8 @@ fun NotificationRationalePermissionContent( @Preview private fun PreviewNotificationRationalePermissionScreen() { FlipcashPreview(showBackground = true) { - CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) { + CompositionLocalProvider(LocalAnalytics provides FlipcashAnalytics.None) { NotificationRationalePermissionContent(onComplete = {}) } } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index 8373dbca93..cbd954cd22 100644 --- a/apps/flipcash/shared/session/build.gradle.kts +++ b/apps/flipcash/shared/session/build.gradle.kts @@ -18,6 +18,7 @@ dependencies { implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:transaction-history")) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:appsettings")) implementation(project(":apps:flipcash:shared:google-play-billing")) implementation(project(":apps:flipcash:shared:featureflags")) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt index 8d2dfa17c8..6b8a82f1eb 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegate.kt @@ -1,7 +1,9 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.State +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.bill.PaymentValuation @@ -59,7 +61,7 @@ class BillPresentationDelegate @Inject constructor( private val toastController: SessionToastController, private val tokenCoordinator: TokenCoordinator, private val walletReveal: WalletRevealCoordinator, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val vibrator: Vibrator, private val resources: ResourceHelper, private val networkObserver: NetworkConnectivityListener, @@ -144,7 +146,7 @@ class BillPresentationDelegate @Inject constructor( } internal fun awaitBillGrab(bill: Scannable.Payable, owner: AccountCluster) { - analytics.transferStart(Analytics.Transfer.Initiate.GiveBillStart) + analytics.track(TransferEvents.giveBillStart()) billController.awaitGrab( amount = bill.amount, token = bill.token, @@ -153,7 +155,7 @@ class BillPresentationDelegate @Inject constructor( owner = owner, onGrabbed = { amount -> tokenCoordinator.subtract(bill.token, amount) - analytics.transfer(Analytics.Transfer.GiveBill, bill.amount) + analytics.track(TransferEvents.giveBill(State.SUCCESS, bill.amount.analytics, null)) toastController.enqueue(bill.amount, isDeposit = false) dismissBill(Grabbed) vibrator.vibrate() @@ -163,11 +165,8 @@ class BillPresentationDelegate @Inject constructor( dismissBill(action = PutInWallet) }, onError = { - analytics.transfer( - event = Analytics.Transfer.GiveBill, - amount = bill.amount, - successful = false, - error = it + analytics.track( + TransferEvents.giveBill(State.FAILURE, bill.amount.analytics, it.analytics) ) dismissBill(action = PutInWallet) BottomBarManager.showError( diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt index baf13c24a3..023df18a8f 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegate.kt @@ -1,7 +1,10 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.State +import com.flipcash.analytics.events.DeeplinkEvents +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.core.navigation.DeeplinkType @@ -49,7 +52,7 @@ class CashLinkDelegate @Inject constructor( private val stateHolder: SessionStateHolder, private val billController: BillController, private val tokenCoordinator: TokenCoordinator, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val resources: ResourceHelper, private val userManager: UserManager, ) : CashLinkOperations, CashLinkClaims { @@ -83,9 +86,11 @@ class CashLinkDelegate @Inject constructor( message = "Cash link not provided", type = TraceType.Silent ) - analytics.deeplinkRouted( - DeeplinkType.CashLink(), - error = IllegalArgumentException("Cash link not provided") + analytics.track( + DeeplinkEvents.routed( + DeeplinkType.CashLink().analytics, + error = IllegalArgumentException("Cash link not provided").analytics, + ) ) return } @@ -97,9 +102,11 @@ class CashLinkDelegate @Inject constructor( message = "No owner found", type = TraceType.Silent ) - analytics.deeplinkRouted( - DeeplinkType.CashLink(), - error = IllegalStateException("No owner found") + analytics.track( + DeeplinkEvents.routed( + DeeplinkType.CashLink().analytics, + error = IllegalStateException("No owner found").analytics, + ) ) return } @@ -110,16 +117,18 @@ class CashLinkDelegate @Inject constructor( message = "Cash link empty", type = TraceType.Silent ) - analytics.deeplinkRouted( - DeeplinkType.CashLink(), - error = IllegalArgumentException("Cash link empty") + analytics.track( + DeeplinkEvents.routed( + DeeplinkType.CashLink().analytics, + error = IllegalArgumentException("Cash link empty").analytics, + ) ) return } if (giftCardClaimInProgress.value == null) { giftCardClaimInProgress.value = entropy - analytics.deeplinkRouted(DeeplinkType.CashLink()) + analytics.track(DeeplinkEvents.routed(DeeplinkType.CashLink().analytics, error = null)) claimGiftCard(owner = owner, entropy = entropy, claimIfOwned = false) } } @@ -143,7 +152,7 @@ class CashLinkDelegate @Inject constructor( tokenCoordinator.add(token, amount) giftCardClaimInProgress.value = null _settledClaims.tryEmit(SettledClaim(entropy, collected = true)) - analytics.transfer(Analytics.Transfer.ClaimedCashLink, amount = amount) + analytics.track(TransferEvents.receiveCashLink(State.SUCCESS, amount.analytics, null)) val bill = Scannable.Payable.forToken( amount = amount, token = token, @@ -157,11 +166,8 @@ class CashLinkDelegate @Inject constructor( giftCardClaimInProgress.value = null _settledClaims.tryEmit(SettledClaim(entropy, collected = false)) if (cause !is ReceiveGiftTransactorError.UsersGiftCard) { - analytics.transfer( - Analytics.Transfer.ClaimedCashLink, - amount = null, - successful = false, - error = cause + analytics.track( + TransferEvents.receiveCashLink(State.FAILURE, null, cause.analytics) ) } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt index f699b5a780..dfd17bd514 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegate.kt @@ -1,7 +1,10 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.State +import com.flipcash.analytics.events.ScanEvents +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.session.CodeScanEvent @@ -54,7 +57,7 @@ class CodeScanDelegate @Inject constructor( private val billController: BillController, private val tokenCoordinator: TokenCoordinator, private val walletReveal: WalletRevealCoordinator, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val vibrator: Vibrator, private val userManager: UserManager, private val dispatchers: DispatcherProvider, @@ -138,7 +141,7 @@ class CodeScanDelegate @Inject constructor( ) val owner = userManager.accountCluster ?: return - analytics.transferStart(Analytics.Transfer.Initiate.GrabBillStart) + analytics.track(TransferEvents.grabBillStart()) billController.attemptGrab( owner = owner, payload = payload, @@ -161,17 +164,14 @@ class CodeScanDelegate @Inject constructor( ) _events.trySend(Event.BillReady(bill)) - analytics.transfer(Analytics.Transfer.GrabBill(grabTime), amount) + analytics.track(TransferEvents.grabBill(State.SUCCESS, amount.analytics, grabTime, null)) BottomBarManager.clear() _events.trySend(Event.CheckPendingFeed) _events.trySend(Event.RefreshFeed) }, onError = { - analytics.transfer( - event = Analytics.Transfer.GrabBill(), - fiat = payload.fiat, - successful = false, - error = it + analytics.track( + TransferEvents.grabBill(State.FAILURE, payload.fiat?.analytics, null, it.analytics) ) scannedRendezvous.remove(payload.rendezvous.publicKey) @@ -191,7 +191,7 @@ class CodeScanDelegate @Inject constructor( // rendezvous grab. Hand the id to the shell, which routes to TipCardOperations to // resolve and present the card. val userId = payload.userId ?: return - analytics.tipCardScanned() + analytics.track(ScanEvents.tipCardScanned()) _events.trySend(Event.TipCardScanned(userId)) } diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt index 2c83b7e7f2..12ac0acf65 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/DepositDelegate.kt @@ -1,7 +1,8 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.session.DepositOperations @@ -35,7 +36,7 @@ class DepositDelegate @Inject constructor( private val usdcSweep: UsdcDepositSweep, private val userManager: UserManager, private val resources: ResourceHelper, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, dispatchers: DispatcherProvider, ) : DepositOperations { @@ -66,7 +67,7 @@ class DepositDelegate @Inject constructor( ) { scope.launch { // the scanner's Give action is the only session caller today - analytics.addMoneyOpened(Analytics.AddMoneySource.Scanner) + analytics.track(AddMoneyEvents.opened(AddMoneySource.SCANNER)) val destination = purchaseMethodController.presentDepositOptions(popToRoot = true) if (destination != null) { onRoute?.invoke(destination) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt index d8090faf1b..e722d092a1 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegate.kt @@ -1,7 +1,10 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.CashLinkChoice +import com.flipcash.analytics.State +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.session.BillDeterminationResult @@ -72,7 +75,7 @@ class GiftCardSharingDelegate @Inject constructor( private val toastController: SessionToastController, private val tokenCoordinator: TokenCoordinator, private val transactionController: TransactionController, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val vibrator: Vibrator, private val resources: ResourceHelper, dispatchers: DispatcherProvider, @@ -204,7 +207,9 @@ class GiftCardSharingDelegate @Inject constructor( _events.trySend(Event.DismissBill(Grabbed)) vibrator.vibrate() _events.trySend(Event.RefreshFeed) - analytics.transfer(Analytics.Transfer.SentCashLink.Clipboard, amount) + analytics.track( + TransferEvents.sendCashLink(State.SUCCESS, amount.analytics, CashLinkChoice.COPIED, null, null) + ) trace( tag = "Session", message = "Cash link copied", @@ -225,9 +230,8 @@ class GiftCardSharingDelegate @Inject constructor( vibrator.vibrate() _events.trySend(Event.RefreshFeed) - analytics.transfer( - event = Analytics.Transfer.SentCashLink.App(name = result.to), - amount = amount + analytics.track( + TransferEvents.sendCashLink(State.SUCCESS, amount.analytics, CashLinkChoice.SHARED, result.to, null) ) trace( diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt index 7d9daec32f..1cecc8473c 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt @@ -1,6 +1,7 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.events.ScanEvents +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.tipping.OwnTipCard import com.flipcash.app.core.tipping.TipCardOwner @@ -48,7 +49,7 @@ import javax.inject.Singleton @Singleton class TipCardDelegate @Inject constructor( private val tippingCoordinator: TippingCoordinator, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val resources: ResourceHelper, dispatchers: DispatcherProvider, ) : TipCardOperations { @@ -100,7 +101,7 @@ class TipCardDelegate @Inject constructor( // Always present the card. Whether the tip modal slides up (or an // add-money prompt shows instead) is decided in the UI from the // coordinator's affordability state — see TipCardDecorator. - analytics.tipCardPresented() + analytics.track(ScanEvents.tipCardPresented()) _events.trySend(Event.Present(card)) } .onFailure { @@ -121,7 +122,7 @@ class TipCardDelegate @Inject constructor( scope.launch { tippingCoordinator.resolveTipCard(username) .onSuccess { card -> - analytics.tipCardPresented() + analytics.track(ScanEvents.tipCardPresented()) _events.trySend(Event.Present(card)) } .onFailure { cause -> diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt index 51cd8374b3..256f76eae8 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/SessionControllerGiftCardErrorTest.kt @@ -1,6 +1,6 @@ package com.flipcash.app.session.internal -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.Scannable @@ -52,7 +52,7 @@ class SessionControllerGiftCardErrorTest { private val resources = FakeResourceHelper() private val tokenCoordinator = mockk(relaxed = true) private val walletReveal = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) private val networkObserver = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) private val dispatchers = TestDispatcherProvider(UnconfinedTestDispatcher()) diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt index b3bcfaf809..0f584fab8d 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/BillPresentationDelegateTest.kt @@ -1,6 +1,9 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.State +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.Scannable @@ -16,6 +19,10 @@ import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.Mint import com.getcode.util.resources.ResourceHelper import com.getcode.util.vibration.Vibrator import com.getcode.utils.network.NetworkConnectivityListener @@ -44,7 +51,7 @@ class BillPresentationDelegateTest { private val billController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private val resources = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) private val walletReveal = mockk(relaxed = true) @@ -55,6 +62,13 @@ class BillPresentationDelegateTest { private val accountCluster = mockk(relaxed = true) + private val localFiat = LocalFiat( + underlyingTokenAmount = Fiat(quarks = 25_000_000L, currencyCode = CurrencyCode.USD), + nativeAmount = Fiat(quarks = 34_000_000L, currencyCode = CurrencyCode.CAD), + rate = Rate(fx = 1.36, currency = CurrencyCode.CAD), + mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + ) + private fun createDelegate(stateHolder: SessionStateHolder = SessionStateHolder()): BillPresentationDelegate { return BillPresentationDelegate( billController = billController, @@ -388,4 +402,68 @@ class BillPresentationDelegateTest { assertTrue(messages.isNotEmpty(), "Expected an error message in BottomBarManager") assertEquals("error_title_CashReturnedToWallet", messages.first().title) } + + @Test + fun `a given bill tracks its start, then Give Bill with the bill's amount`() = runTest { + val onGrabbed = slot Unit>() + every { + billController.awaitGrab( + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + nonce = any(), + present = any(), + onGrabbed = capture(onGrabbed), + onTimeout = any(), + onError = any(), + ) + } answers {} + + val delegate = createDelegate() + delegate.awaitBillGrab(cashBill(), accountCluster) + onGrabbed.captured.invoke(localFiat) + + assertEquals( + listOf( + TransferEvents.giveBillStart(), + TransferEvents.giveBill(State.SUCCESS, localFiat.analytics, error = null), + ), + analytics.events, + ) + } + + @Test + fun `a failed give tracks Give Bill as a failure with the bill's amount`() = runTest { + val onError = slot<(Throwable) -> Unit>() + every { + billController.awaitGrab( + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + nonce = any(), + present = any(), + onGrabbed = any(), + onTimeout = any(), + onError = capture(onError), + ) + } answers {} + + val delegate = createDelegate() + delegate.awaitBillGrab(cashBill(), accountCluster) + onError.captured.invoke(RuntimeException("give failed")) + + assertEquals( + TransferEvents.giveBill(State.FAILURE, localFiat.analytics, error = "give failed"), + analytics.events.last(), + ) + } + + private fun cashBill() = Scannable.CashBill( + token = mockk(relaxed = true), + amount = localFiat, + didReceive = false, + kind = Scannable.Payable.Kind.cash, + ) } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt index 23ae4c5b07..3412720005 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CashLinkDelegateTest.kt @@ -1,6 +1,10 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.State +import com.flipcash.analytics.events.DeeplinkEvents +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.internal.bill.BillController import com.flipcash.app.session.SettledClaim @@ -10,6 +14,10 @@ import com.flipcash.services.user.UserManager import com.getcode.manager.BottomBarManager import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.Mint import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper import io.mockk.every @@ -35,12 +43,19 @@ class CashLinkDelegateTest { private val billController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private val resources = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) + private val localFiat = LocalFiat( + underlyingTokenAmount = Fiat(quarks = 25_000_000L, currencyCode = CurrencyCode.USD), + nativeAmount = Fiat(quarks = 34_000_000L, currencyCode = CurrencyCode.CAD), + rate = Rate(fx = 1.36, currency = CurrencyCode.CAD), + mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + ) + private fun createDelegate(): CashLinkDelegate { return CashLinkDelegate( stateHolder = SessionStateHolder(), @@ -77,9 +92,10 @@ class CashLinkDelegateTest { onError = any(), ) } - verify { - analytics.deeplinkRouted(any(), error = any()) - } + assertEquals( + DeeplinkEvents.routed("CashLink", error = "Cash link not provided"), + analytics.events.single(), + ) } @Test @@ -130,9 +146,10 @@ class CashLinkDelegateTest { onError = any(), ) } - verify { - analytics.deeplinkRouted(any(), error = any()) - } + assertEquals( + DeeplinkEvents.routed("CashLink", error = "No owner found"), + analytics.events.single(), + ) } @Test @@ -149,6 +166,10 @@ class CashLinkDelegateTest { onError = any(), ) } + assertEquals( + DeeplinkEvents.routed("CashLink", error = null), + analytics.events.single { it.name == "Deeplink: Routed" }, + ) } @Test @@ -206,10 +227,14 @@ class CashLinkDelegateTest { onError = any(), ) } - onReceived.captured.invoke(mockk(relaxed = true), mockk(relaxed = true)) + onReceived.captured.invoke(mockk(relaxed = true), localFiat) runCurrent() assertEquals(listOf(SettledClaim("validEntropy123", collected = true)), settled) + assertEquals( + TransferEvents.receiveCashLink(State.SUCCESS, localFiat.analytics, error = null), + analytics.events.single { it.name == "Receive Cash Link" }, + ) } @Test @@ -236,6 +261,10 @@ class CashLinkDelegateTest { runCurrent() assertEquals(listOf(SettledClaim("validEntropy123", collected = false)), settled) + assertEquals( + TransferEvents.receiveCashLink(State.FAILURE, amount = null, error = "already claimed"), + analytics.events.single { it.name == "Receive Cash Link" }, + ) } @Test diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt index 4d28795a60..0612cdefec 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt @@ -1,6 +1,11 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.PropertyValue +import com.flipcash.analytics.State +import com.flipcash.analytics.events.ScanEvents +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.internal.bill.BillController @@ -13,6 +18,10 @@ import com.getcode.opencode.model.core.OpenCodePayload import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.model.core.PayloadKind import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.Mint import com.getcode.opencode.model.financial.Token import com.getcode.util.vibration.Vibrator import com.kik.kikx.models.ScannableKikCode @@ -31,6 +40,7 @@ import org.junit.Before import org.junit.Rule import org.junit.Test import kotlin.test.assertEquals +import kotlin.test.assertIs import kotlin.test.assertNull import kotlin.test.assertTrue @@ -42,7 +52,7 @@ class CodeScanDelegateTest { private val billController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private val vibrator = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) private val walletReveal = mockk(relaxed = true) @@ -50,6 +60,13 @@ class CodeScanDelegateTest { private val stateHolder = SessionStateHolder() + private val localFiat = LocalFiat( + underlyingTokenAmount = Fiat(quarks = 25_000_000L, currencyCode = CurrencyCode.USD), + nativeAmount = Fiat(quarks = 34_000_000L, currencyCode = CurrencyCode.CAD), + rate = Rate(fx = 1.36, currency = CurrencyCode.CAD), + mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + ) + private val mockPayload = mockk(relaxed = true) { every { kind } returns PayloadKind.Cash every { rendezvous.publicKey } returns "test-rendezvous-key" @@ -260,6 +277,57 @@ class CodeScanDelegateTest { } } + @Test + fun `a grab tracks its start, then Grab Bill with the amount it landed`() = runTest { + val onGrabbed = slot Unit>() + every { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = capture(onGrabbed), + onError = any(), + ) + } answers {} + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + onGrabbed.captured.invoke(mockk(relaxed = true), localFiat, null) + + assertEquals(2, analytics.events.size) + assertEquals(TransferEvents.grabBillStart(), analytics.events[0]) + // Grab Time is measured from the scan, so only its type is fixed here. + val grab = analytics.events[1] + assertIs(grab.properties["Grab Time"]) + assertEquals( + TransferEvents.grabBill(State.SUCCESS, localFiat.analytics, grabTimeMillis = null, error = null), + grab.copy(properties = grab.properties - "Grab Time"), + ) + } + + @Test + fun `a failed grab tracks Grab Bill as a failure with the scanned fiat`() = runTest { + val scanned = Fiat(quarks = 5_000_000L, currencyCode = CurrencyCode.USD) + every { mockPayload.fiat } returns scanned + val onError = slot<(Throwable) -> Unit>() + every { + billController.attemptGrab( + owner = any(), + payload = any(), + onGrabbed = any(), + onError = capture(onError), + ) + } answers {} + + val delegate = createDelegate() + delegate.onCodeScan(remoteKikCode()) + onError.captured.invoke(RuntimeException("grab failed")) + + assertEquals( + TransferEvents.grabBill(State.FAILURE, scanned.analytics, grabTimeMillis = null, error = "grab failed"), + analytics.events.last(), + ) + } + // --- Error clears rendezvous so re-scan is possible --- @Test @@ -307,7 +375,7 @@ class CodeScanDelegateTest { // dismissed bill has already re-opened the "a bill is up" gate at the top of onCodeScan. delegate.onCodeScan(remoteKikCode()) - verify(exactly = 1) { analytics.tipCardScanned() } + assertEquals(listOf(ScanEvents.tipCardScanned()), analytics.events) } @Test @@ -321,6 +389,6 @@ class CodeScanDelegateTest { every { mockPayload.userId } returns listOf(2.toByte()) delegate.onCodeScan(remoteKikCode()) - verify(exactly = 2) { analytics.tipCardScanned() } + assertEquals(listOf(ScanEvents.tipCardScanned(), ScanEvents.tipCardScanned()), analytics.events) } } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt index 577aeefd53..8fc422413d 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/DelegateEventEmissionTest.kt @@ -1,7 +1,7 @@ package com.flipcash.app.session.internal.delegates import app.cash.turbine.test -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.Scannable @@ -44,7 +44,7 @@ class DelegateEventEmissionTest { private val billController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + private val analytics = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) private val walletReveal = mockk(relaxed = true) private val networkObserver = mockk(relaxed = true) diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt index d8c8bd23d4..d3c25fd665 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/GiftCardSharingDelegateTest.kt @@ -1,7 +1,11 @@ package com.flipcash.app.session.internal.delegates import app.cash.turbine.test -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.CashLinkChoice +import com.flipcash.analytics.State +import com.flipcash.analytics.events.TransferEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.Scannable @@ -22,6 +26,10 @@ import com.getcode.opencode.internal.manager.VerifiedState import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.accounts.GiftCardAccount import com.getcode.opencode.model.financial.LocalFiat +import com.getcode.opencode.model.financial.CurrencyCode +import com.getcode.opencode.model.financial.Fiat +import com.getcode.opencode.model.financial.Rate +import com.getcode.solana.keys.Mint import com.getcode.opencode.model.financial.Token import com.getcode.util.resources.ResourceHelper import com.getcode.util.vibration.Vibrator @@ -42,6 +50,7 @@ import org.junit.After import org.junit.Before import org.junit.Rule import org.junit.Test +import kotlin.test.assertEquals import kotlin.test.assertIs @OptIn(ExperimentalCoroutinesApi::class) @@ -55,7 +64,7 @@ class GiftCardSharingDelegateTest { private val toastController = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) private val transactionController = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private val vibrator = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val accountCluster = mockk(relaxed = true) @@ -63,6 +72,13 @@ class GiftCardSharingDelegateTest { private val amount = mockk(relaxed = true) private val token = mockk(relaxed = true) + private val localFiat = LocalFiat( + underlyingTokenAmount = Fiat(quarks = 25_000_000L, currencyCode = CurrencyCode.USD), + nativeAmount = Fiat(quarks = 34_000_000L, currencyCode = CurrencyCode.CAD), + rate = Rate(fx = 1.36, currency = CurrencyCode.CAD), + mint = Mint("EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v"), + ) + private lateinit var shareSheetController: ShareSheetController @Before @@ -318,4 +334,58 @@ class GiftCardSharingDelegateTest { ) } } + + @Test + fun `a copied cash link tracks Send Cash Link with the clipboard choice`() = runTest { + sendConfirmedCashLink(ShareResult.CopiedToClipboard) + + assertEquals( + TransferEvents.sendCashLink(State.SUCCESS, localFiat.analytics, CashLinkChoice.COPIED, app = null, error = null), + analytics.events.single(), + ) + } + + @Test + fun `a cash link shared to an app tracks Send Cash Link with the app`() = runTest { + sendConfirmedCashLink(ShareResult.SharedToApp(to = "com.example.messages")) + + assertEquals( + TransferEvents.sendCashLink(State.SUCCESS, localFiat.analytics, CashLinkChoice.SHARED, app = "com.example.messages", error = null), + analytics.events.single(), + ) + } + + @Suppress("UNCHECKED_CAST") + private fun TestScope.sendConfirmedCashLink(result: ShareResult) { + every { + billController.fundGiftCard( + giftCard = any(), + amount = any(), + token = any(), + owner = any(), + verifiedState = any(), + onFunded = any(), + onError = any(), + ) + } answers { + val onFunded = args[5] as suspend (LocalFiat) -> Unit + runBlocking { onFunded(localFiat) } + } + coEvery { + shareConfirmationController.confirm(any(), any()) + } answers { ShareConfirmationResult.Confirmed(secondArg()) } + + val bill = Scannable.CashBill( + token = token, + amount = localFiat, + didReceive = false, + kind = Scannable.Payable.Kind.cash, + verifiedState = verifiedState, + ) + val delegate = createDelegate() + delegate.shareGiftCard(bill, accountCluster) + advanceUntilIdle() + shareSheetController.onShared?.invoke(result) + advanceUntilIdle() + } } diff --git a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt index 5e2b8b0434..3efc882ba1 100644 --- a/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt +++ b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegateTest.kt @@ -1,6 +1,7 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.events.ScanEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.MainCoroutineRule import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.tipping.TipCardOwner @@ -38,7 +39,7 @@ class TipCardDelegateTest { private val tippingCoordinator = mockk(relaxed = true) { every { currentUserId } returns self } - private val analytics = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private val resources = mockk(relaxed = true) private fun delegate() = TipCardDelegate( @@ -59,6 +60,7 @@ class TipCardDelegateTest { assertEquals(TipCardEvent.OwnCardScanned, event.await()) coVerify(exactly = 0) { tippingCoordinator.resolveTipCard(any()) } + assertEquals(emptyList(), analytics.events) } @Test @@ -73,5 +75,21 @@ class TipCardDelegateTest { delegate.resolveTipCard(TipCardOwner.ById(other)) assertEquals(TipCardDelegate.Event.Present(card), presented.await()) + assertEquals(listOf(ScanEvents.tipCardPresented()), analytics.events) + } + + @Test + fun `resolving another user's tip card by username resolves and presents it`() = runTest { + val card = mockk(relaxed = true) + coEvery { tippingCoordinator.resolveTipCard("other-user") } returns Result.success(card) + + val delegate = delegate() + val presented = async { delegate.events.first() } + testScheduler.advanceUntilIdle() + + delegate.resolveTipCard(TipCardOwner.ByUsername("other-user")) + + assertEquals(TipCardDelegate.Event.Present(card), presented.await()) + assertEquals(listOf(ScanEvents.tipCardPresented()), analytics.events) } } diff --git a/apps/flipcash/shared/tipping/build.gradle.kts b/apps/flipcash/shared/tipping/build.gradle.kts index 2e11717028..006859674a 100644 --- a/apps/flipcash/shared/tipping/build.gradle.kts +++ b/apps/flipcash/shared/tipping/build.gradle.kts @@ -11,7 +11,6 @@ dependencies { testImplementation(libs.bundles.unit.testing) testImplementation(libs.robolectric) - implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:chat")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:payments")) 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 9e8de38e87..e621c9d418 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 @@ -1,7 +1,5 @@ package com.flipcash.shared.tipping -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.tipping.OwnTipCard @@ -75,7 +73,6 @@ class TippingCoordinator @Inject constructor( private val verifiedFiatCalculator: VerifiedFiatCalculator, private val resources: ResourceHelper, private val purchaseMethodController: PurchaseMethodController, - private val analytics: FlipcashAnalyticsService, private val vibrator: Vibrator, ) : TipSelectionHolder { /** The signed-in user's id ([UserManager.accountId]), or null if unavailable. */ 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 693bedd71f..943c2d971b 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,6 +1,5 @@ package com.flipcash.shared.tipping -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ProfileController @@ -29,7 +28,6 @@ class TippingCoordinatorTest { private val verifiedFiatCalculator = mockk(relaxed = true) private val resources = mockk(relaxed = true) private val purchaseMethodController = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) private val vibrator = mockk(relaxed = true) private fun buildCoordinator() = TippingCoordinator( @@ -41,7 +39,6 @@ class TippingCoordinatorTest { verifiedFiatCalculator, resources, purchaseMethodController, - analytics, vibrator, ) diff --git a/apps/flipcash/shared/tokens/build.gradle.kts b/apps/flipcash/shared/tokens/build.gradle.kts index 471951ac69..b606a23a39 100644 --- a/apps/flipcash/shared/tokens/build.gradle.kts +++ b/apps/flipcash/shared/tokens/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(project(":apps:flipcash:shared:funding")) implementation(project(":apps:flipcash:shared:transaction-history")) implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:onramp:coinbase")) implementation(project(":apps:flipcash:shared:onramp:deeplinks")) diff --git a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt index 8a16d289e9..1ef2eb8a3d 100644 --- a/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt +++ b/apps/flipcash/shared/tokens/src/main/kotlin/com/flipcash/app/tokens/ui/SwapViewModel.kt @@ -1,10 +1,18 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.AddMoneyMethod +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.PurchaseMethod as AnalyticsPurchaseMethod +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.analytics.events.ButtonEvents +import com.flipcash.analytics.events.SwapEvents +import com.flipcash.analytics.events.WalletEvents import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator -import com.flipcash.app.analytics.Analytics -import com.flipcash.app.analytics.Button -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.Button +import com.flipcash.app.analytics.FlipcashAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.to @@ -114,7 +122,7 @@ class SwapViewModel @Inject constructor( private val resources: ResourceHelper, private val tokenCoordinator: TokenCoordinator, feedCoordinator: ActivityFeedCoordinator, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, private val purchaseMethodController: PurchaseMethodController, private val coinbaseOnRampController: CoinbaseOnRampController, private val phantomWalletController: PhantomWalletController, @@ -466,13 +474,13 @@ class SwapViewModel @Inject constructor( // "Add Money" funnel events only apply when acquiring USDF via an external // funding source; token buys funded from reserves or launchpad purchases are // tracked separately via buy()/sell(). - private val addMoneyMethod: Analytics.AddMoneyMethod? + private val addMoneyMethod: AddMoneyMethod? get() { val purpose = stateFlow.value.purpose as? SwapPurpose.Buy ?: return null if (purpose.mint != Mint.usdf) return null return when (purpose.fundingSource) { - FundingSource.Coinbase -> Analytics.AddMoneyMethod.Coinbase - FundingSource.Phantom -> Analytics.AddMoneyMethod.Phantom + FundingSource.Coinbase -> AddMoneyMethod.COINBASE + FundingSource.Phantom -> AddMoneyMethod.PHANTOM FundingSource.Flexible -> null } } @@ -905,12 +913,14 @@ class SwapViewModel @Inject constructor( .onEach { (delegateState, purpose) -> val isAddingMoney = stateFlow.value.isAddingMoney addMoneyMethod?.let { method -> - analytics.addMoneyAmountConfirmed( - method = method, - amount = Fiat( - delegateState.enteredAmount, - delegateState.currency.code ?: CurrencyCode.USD, - ), + analytics.track( + AddMoneyEvents.amountConfirmed( + method = method, + amount = Fiat( + delegateState.enteredAmount, + delegateState.currency.code ?: CurrencyCode.USD, + ).analytics, + ) ) } when (purpose) { @@ -1109,11 +1119,13 @@ class SwapViewModel @Inject constructor( OrderDeliveryResult.Failed -> { dispatchEvent(Event.UpdateProcessingState(loading = false, error = true)) addMoneyMethod?.let { method -> - analytics.addMoney( - method = method, - amount = netTransferAmount, - successful = false, - error = IllegalStateException("Order delivery failed"), + analytics.track( + AddMoneyEvents.result( + method = method, + state = AnalyticsState.FAILURE, + amount = netTransferAmount.analytics, + error = IllegalStateException("Order delivery failed").analytics, + ) ) } false @@ -1123,11 +1135,13 @@ class SwapViewModel @Inject constructor( OrderDeliveryResult.TimedOut -> { dispatchEvent(Event.UpdateProcessingState(loading = false, error = true)) addMoneyMethod?.let { method -> - analytics.addMoney( - method = method, - amount = netTransferAmount, - successful = false, - error = IllegalStateException("Order delivery timed out"), + analytics.track( + AddMoneyEvents.result( + method = method, + state = AnalyticsState.FAILURE, + amount = netTransferAmount.analytics, + error = IllegalStateException("Order delivery timed out").analytics, + ) ) } false @@ -1146,7 +1160,9 @@ class SwapViewModel @Inject constructor( feedCoordinator.fetchSinceLatest() dispatchEvent(Event.UpdateProcessingState(loading = false, success = true)) addMoneyMethod?.let { method -> - analytics.addMoney(method, netTransferAmount) + analytics.track( + AddMoneyEvents.result(method, AnalyticsState.SUCCESS, netTransferAmount.analytics, null) + ) } }.launchIn(viewModelScope) @@ -1554,7 +1570,9 @@ class SwapViewModel @Inject constructor( when (s) { is CoinbaseOnRampState.Failed -> { addMoneyMethod?.let { method -> - analytics.addMoney(method, successful = false, error = s.error) + analytics.track( + AddMoneyEvents.result(method, AnalyticsState.FAILURE, null, s.error.analytics) + ) } dispatchEvent(Event.UpdateBuyState()) } @@ -1578,7 +1596,7 @@ class SwapViewModel @Inject constructor( val amount = metadata.purchaseAmount ?: return@onEach when (method) { PurchaseMethod.CoinbaseOnRamp -> { - analytics.buttonTapped(Button.TokenBuyWithCoinbase) + analytics.track(ButtonEvents.tapped(Button.BUY_WITH_COINBASE)) dispatchEvent(Event.CoinbaseSelected) val profile = userManager.profile @@ -1642,12 +1660,12 @@ class SwapViewModel @Inject constructor( } PurchaseMethod.PhantomWallet -> { - analytics.buttonTapped(Button.TokenBuyWithPhantom) + analytics.track(ButtonEvents.tapped(Button.BUY_WITH_PHANTOM)) dispatchEvent(Event.PhantomSelected) } PurchaseMethod.OtherWallet -> { - analytics.buttonTapped(Button.TokenBuyWithOtherWallet) + analytics.track(ButtonEvents.tapped(Button.BUY_WITH_OTHER_WALLET)) dispatchEvent(Event.OtherWalletSelected) } } @@ -1656,7 +1674,7 @@ class SwapViewModel @Inject constructor( eventFlow .filterIsInstance() .mapNotNull { - analytics.addMoneyOpened(Analytics.AddMoneySource.BuyShortfall) + analytics.track(AddMoneyEvents.opened(AddMoneySource.BUY_SHORTFALL)) // present the add-money/deposit sheet; navigate to whatever the user picks. // popToRoot = false: the chosen add-money route replaces this buy flow (see the // OpenScreen handler in SwapEntryScreen), so finishing it pops a single level back @@ -1738,7 +1756,9 @@ class SwapViewModel @Inject constructor( ).onSuccess { trackTransaction(token) addMoneyMethod?.let { method -> - analytics.addMoneyPaymentInvoked(method, amountFiat.localFiat.nativeAmount) + analytics.track( + AddMoneyEvents.paymentInvoked(method, amountFiat.localFiat.nativeAmount.analytics) + ) } }.onFailure { error -> dispatchEvent(Event.UpdateBuyState()) @@ -1752,11 +1772,13 @@ class SwapViewModel @Inject constructor( else -> { trackTransaction(token, error = error) addMoneyMethod?.let { method -> - analytics.addMoney( - method = method, - amount = amountFiat.localFiat.nativeAmount, - successful = false, - error = error, + analytics.track( + AddMoneyEvents.result( + method = method, + state = AnalyticsState.FAILURE, + amount = amountFiat.localFiat.nativeAmount.analytics, + error = error.analytics, + ) ) } BottomBarManager.showError( @@ -1769,34 +1791,36 @@ class SwapViewModel @Inject constructor( } private fun trackTransaction(token: Token, error: Throwable? = null) { - val method = when (val purpose = stateFlow.value.purpose) { + val purchaseMethod = when (val purpose = stateFlow.value.purpose) { is SwapPurpose.Buy -> when (purpose.fundingSource) { - FundingSource.Phantom -> Analytics.SwapMethod.Buy.Phantom - FundingSource.Coinbase -> Analytics.SwapMethod.Buy.Coinbase - else -> Analytics.SwapMethod.Buy.Reserves + FundingSource.Phantom -> AnalyticsPurchaseMethod.PHANTOM + FundingSource.Coinbase -> AnalyticsPurchaseMethod.COINBASE + else -> AnalyticsPurchaseMethod.RESERVES } - else -> Analytics.SwapMethod.Sell + else -> null } - when (method) { - Analytics.SwapMethod.Sell -> { - analytics.sell( - amount = netTransferAmount, - feeAmount = feeAmount, - mint = token.address, - error = error - ) - } + val errorMessage = error?.analytics - is Analytics.SwapMethod.Buy -> { - analytics.buy( - method = method.with, - amount = netTransferAmount, - mint = token.address, - error = error + if (purchaseMethod != null) { + analytics.track( + SwapEvents.purchase( + method = purchaseMethod, + mint = token.address.analytics, + amount = netTransferAmount.analytics, + error = errorMessage, ) - } + ) + } else { + analytics.track( + SwapEvents.sell( + mint = token.address.analytics, + amount = netTransferAmount.analytics, + fee = feeAmount.decimalValue, + error = errorMessage, + ) + ) } } @@ -1809,7 +1833,7 @@ class SwapViewModel @Inject constructor( dispatchEvent(Event.UpdateBuyState(success = true)) dispatchEvent(Event.PhantomConnected) dispatchEvent(Event.UpdateBuyState()) - analytics.connectWallet(OnRampProvider.Phantom) + analytics.track(WalletEvents.connect(OnRampProvider.Phantom.analytics)) }.onFailure { dispatchEvent(Event.UpdateBuyState()) handlePhantomError(it) @@ -1827,12 +1851,16 @@ class SwapViewModel @Inject constructor( fee = LocalFiat.Zero, token = token, onBeforeSign = { - analytics.amountSelectedForWalletTransfer( - OnRampProvider.Phantom, - amount.localFiat.underlyingTokenAmount + analytics.track( + WalletEvents.requestAmount( + OnRampProvider.Phantom.analytics, + amount.localFiat.underlyingTokenAmount.analytics, + ) ) addMoneyMethod?.let { method -> - analytics.addMoneyPaymentInvoked(method, amount.localFiat.nativeAmount) + analytics.track( + AddMoneyEvents.paymentInvoked(method, amount.localFiat.nativeAmount.analytics) + ) } }, ).onSuccess { result -> @@ -1859,13 +1887,15 @@ class SwapViewModel @Inject constructor( ?: DeeplinkOnRampError.FailedToCreateTransaction(message = error.message, cause = error) if (deeplinkError is DeeplinkOnRampError.WalletProvidedError && deeplinkError.code == DeeplinkError.UserRejectedRequest.code) { - analytics.walletTransactionCancelled(OnRampProvider.Phantom) + analytics.track(WalletEvents.cancel(OnRampProvider.Phantom.analytics)) } else { if (deeplinkError is DeeplinkOnRampError.FailedToSendTransaction) { - analytics.walletTransactionFailed(OnRampProvider.Phantom) + analytics.track(WalletEvents.transactionsFailed(OnRampProvider.Phantom.analytics)) } addMoneyMethod?.let { method -> - analytics.addMoney(method, successful = false, error = deeplinkError) + analytics.track( + AddMoneyEvents.result(method, AnalyticsState.FAILURE, null, deeplinkError.analytics) + ) } } @@ -2092,4 +2122,4 @@ class SwapViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt index 551edf73e2..c21e851380 100644 --- a/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt +++ b/apps/flipcash/shared/tokens/src/test/kotlin/com/flipcash/app/tokens/ui/SwapViewModelErrorTest.kt @@ -1,11 +1,24 @@ package com.flipcash.app.tokens.ui import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.analytics.AddMoneyMethod +import com.flipcash.analytics.Amount +import com.flipcash.analytics.PropertyValue +import com.flipcash.analytics.PurchaseMethod +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.WalletProvider +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.analytics.events.SwapEvents +import com.flipcash.analytics.events.WalletEvents +import com.flipcash.app.analytics.RecordingAnalytics +import com.flipcash.app.analytics.analytics import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.onramp.CoinbaseOnRampController +import com.flipcash.app.onramp.DeeplinkError +import com.flipcash.app.onramp.DeeplinkOnRampError import com.flipcash.app.funding.PurchaseMethodController +import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.tokens.UsdcDepositSweep import com.flipcash.services.user.UserManager @@ -15,6 +28,7 @@ import com.getcode.opencode.controllers.TransactionOperations import com.getcode.opencode.exchange.Exchange import com.getcode.opencode.exchange.VerifiedFiat import com.getcode.opencode.exchange.VerifiedFiatCalculator +import com.getcode.opencode.internal.solana.model.SwapId import com.getcode.opencode.model.accounts.AccountCluster import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.Currency @@ -24,6 +38,7 @@ import com.getcode.opencode.model.financial.Rate import com.getcode.opencode.model.financial.SendLimit import com.getcode.opencode.model.financial.LocalFiat import com.getcode.solana.keys.Mint +import com.getcode.solana.keys.base58 import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.TokenWithBalance import com.getcode.opencode.utils.generate @@ -56,7 +71,9 @@ import org.mockito.kotlin.doReturn import org.mockito.kotlin.mock import org.mockito.kotlin.stub import org.mockito.kotlin.whenever +import kotlin.test.assertEquals import kotlin.test.assertFalse +import kotlin.test.assertIs import kotlin.test.assertTrue @OptIn(ExperimentalCoroutinesApi::class) @@ -74,7 +91,7 @@ class SwapViewModelErrorTest { private val resources = FakeResourceHelper() private val tokenCoordinator = mockk(relaxed = true) private val feedCoordinator = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + private val analytics = RecordingAnalytics() private val purchaseMethodController = mockk(relaxed = true) private val coinbaseOnRampController = mockk(relaxed = true) private val phantomWalletController = mockk(relaxed = true) @@ -146,7 +163,9 @@ class SwapViewModelErrorTest { Result.failure(RuntimeException("buy failed")) } - val token = mockk(relaxed = true) + val token = mockk(relaxed = true) { + every { address } returns Mint.usdf + } val tokenWithBalance = mockk(relaxed = true) { every { this@mockk.token } returns token } @@ -155,10 +174,28 @@ class SwapViewModelErrorTest { val vm = createViewModel() vm.dispatchEvent(SwapViewModel.Event.OnPurposeChanged(SwapPurpose.Buy(mockk(relaxed = true)))) vm.dispatchEvent(SwapViewModel.Event.OnSelectedTokenChanged(tokenWithBalance)) + // A straight reserves buy funds itself from the target token; without this the + // ViewModel short-circuits before ever calling transactionController.buy. + vm.dispatchEvent(SwapViewModel.Event.OnFundingTokenResolved(tokenWithBalance)) vm.dispatchEvent(SwapViewModel.Event.ProceedWithPurchase(amount)) advanceUntilIdle() assertTrue(BottomBarManager.messages.value.any { it.title == "error_title_buySellFailed" }) + + val result = analytics.events.single { it.name == "Token Purchase With Reserves" } + assertIs(result.properties["Fiat"]) + // The amount block is the net of the entry and its fee; only its shape is fixed here. + val amountKeys = setOf("Fiat", "Currency", "USDC", "Quarks") + val expected = SwapEvents.purchase( + PurchaseMethod.RESERVES, + Mint.usdf.base58(), + amount = Amount(fiat = 0.0, currency = "", usdc = 0.0, quarks = 0), + error = "buy failed", + ) + assertEquals( + expected.copy(properties = expected.properties - amountKeys), + result.copy(properties = result.properties - amountKeys), + ) } @Test @@ -184,6 +221,22 @@ class SwapViewModelErrorTest { advanceUntilIdle() assertTrue(BottomBarManager.messages.value.any { it.title == "error_title_buySellFailed" }) + + val result = analytics.events.single { it.name == "Token Sell" } + assertIs(result.properties["Fee"]) + // The amount and fee are computed off launchpad metadata the relaxed token mock doesn't + // model; only their shape is fixed here. + val amountKeys = setOf("Fiat", "Currency", "USDC", "Quarks", "Fee") + val expected = SwapEvents.sell( + mint = Mint.usdf.base58(), + amount = Amount(fiat = 0.0, currency = "", usdc = 0.0, quarks = 0), + fee = 0.0, + error = "sell failed", + ) + assertEquals( + expected.copy(properties = expected.properties - amountKeys), + result.copy(properties = result.properties - amountKeys), + ) } @Test @@ -216,6 +269,152 @@ class SwapViewModelErrorTest { verify(exactly = 0) { usdcDepositSweep.execute(any()) } } + // --- Wallet (Phantom) events ----------------------------------------------------------- + + @Test + fun `connecting phantom successfully tracks Wallet Connect`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + coEvery { phantomWalletController.connectWallet() } returns Result.success(Unit) + + val vm = createViewModel() + vm.dispatchEvent(SwapViewModel.Event.StartPhantomCeremony) + advanceUntilIdle() + + assertEquals( + WalletEvents.connect(OnRampProvider.Phantom.analytics), + analytics.events.single(), + ) + } + + @Test + fun `user rejecting the phantom connect tracks Wallet Cancel`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + coEvery { phantomWalletController.connectWallet() } returns Result.failure( + DeeplinkOnRampError.WalletProvidedError( + DeeplinkError.UserRejectedRequest, + message = "User cancelled connection", + ) + ) + + val vm = createViewModel() + vm.dispatchEvent(SwapViewModel.Event.StartPhantomCeremony) + advanceUntilIdle() + + assertEquals( + WalletEvents.cancel(OnRampProvider.Phantom.analytics), + analytics.events.single(), + ) + } + + @Test + fun `a failed send during the phantom connect tracks Wallet Transactions Failed`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + coEvery { phantomWalletController.connectWallet() } returns Result.failure( + DeeplinkOnRampError.FailedToSendTransaction(code = 7L, message = "boom") + ) + + val vm = createViewModel() + vm.dispatchEvent(SwapViewModel.Event.StartPhantomCeremony) + advanceUntilIdle() + + assertEquals( + WalletEvents.transactionsFailed(OnRampProvider.Phantom.analytics), + analytics.events.single { it.name == "Wallet: Transactions Failed" }, + ) + } + + @Test + fun `confirming a phantom transaction tracks Wallet Request Amount with the token amount`() = + runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { exchange.preferredRate } returns Rate.oneToOne + + val verifiedFiat = VerifiedFiat( + LocalFiat.fromUsd(usdf = Fiat(12.0, CurrencyCode.USD)), + null, + ) + coEvery { + verifiedFiatCalculator.compute(any(), any(), any(), any(), any()) + } returns Result.success(verifiedFiat) + + val token = mockk(relaxed = true) { + every { address } returns Mint.usdf + } + val tokenWithBalance = mockk(relaxed = true) { + every { this@mockk.token } returns token + } + + // Fail the swap after the pre-sign callback fires, rather than succeeding — a + // successful WithSwapId result drives an unrelated swap-polling collector that + // would need its own mocking and isn't part of what this test is checking. + val swapId = SwapId(ByteArray(32) { 0 }.toList()) + coEvery { + phantomWalletController.executeSwap(any(), any(), any(), any()) + } coAnswers { + val onBeforeSign = arg<(suspend (SwapId) -> Unit)?>(3) + onBeforeSign?.invoke(swapId) + Result.failure(DeeplinkOnRampError.FailedToCreateTransaction(message = "boom")) + } + + val vm = createViewModel() + vm.dispatchEvent( + SwapViewModel.Event.OnPurposeChanged( + SwapPurpose.Buy(Mint.usdf, fundingSource = FundingSource.Phantom) + ) + ) + vm.dispatchEvent(SwapViewModel.Event.OnSelectedTokenChanged(tokenWithBalance)) + vm.amountDelegate.setAmount(12.0) + vm.dispatchEvent(SwapViewModel.Event.ConfirmPhantomTransaction) + advanceUntilIdle() + + assertEquals( + WalletEvents.requestAmount( + OnRampProvider.Phantom.analytics, + verifiedFiat.localFiat.underlyingTokenAmount.analytics, + ), + analytics.events.single { it.name == "Wallet: Request Amount" }, + ) + } + + @Test + fun `a failed Coinbase delivery tracks Add Money as a failure`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { tokenCoordinator.balanceForToken(Mint.usdf) } returns MutableStateFlow(Fiat.Zero) + coEvery { coinbaseOnRampController.awaitOrderDelivered("order-3") } returns + OrderDeliveryResult.Failed + + val vm = createViewModel() + vm.dispatchEvent( + SwapViewModel.Event.OnPurposeChanged(SwapPurpose.Buy(Mint.usdf, fundingSource = FundingSource.Coinbase)) + ) + vm.dispatchEvent(SwapViewModel.Event.DepositSubmitted(orderId = "order-3")) + advanceUntilIdle() + + val result = analytics.events.single { it.name == "Add Money" } + // The amount block is the net of the entry and its fee; only its shape is fixed here. + assertIs(result.properties["Fiat"]) + val amountKeys = setOf("Fiat", "Currency", "USDC", "Quarks", "Exchange Rate", "Mint") + assertEquals( + AddMoneyEvents.result(AddMoneyMethod.COINBASE, AnalyticsState.FAILURE, amount = null, error = "Order delivery failed"), + result.copy(properties = result.properties - amountKeys), + ) + } + + @Test + fun `a deposit outside a USDF buy tracks no Add Money result`() = runTest(mainCoroutineRule.dispatcher) { + dispatchers = TestDispatchers(testScheduler) + every { tokenCoordinator.balanceForToken(Mint.usdf) } returns MutableStateFlow(Fiat.Zero) + coEvery { coinbaseOnRampController.awaitOrderDelivered("order-4") } returns + OrderDeliveryResult.Failed + + val vm = createViewModel() + vm.dispatchEvent(SwapViewModel.Event.DepositSubmitted(orderId = "order-4")) + advanceUntilIdle() + + assertTrue(analytics.events.none { it.name == "Add Money" }) + } + // --- Entry-currency conversion on the buy gate --------------------------------------------- // // transactionLimit() is USD-denominated. A user entering in a currency that trades well below diff --git a/libs/analytics/.gitignore b/libs/analytics/.gitignore deleted file mode 100644 index 9f2a078806..0000000000 --- a/libs/analytics/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -build/ -.gradle/ diff --git a/libs/analytics/build.gradle.kts b/libs/analytics/build.gradle.kts deleted file mode 100644 index 688cdac95c..0000000000 --- a/libs/analytics/build.gradle.kts +++ /dev/null @@ -1,13 +0,0 @@ -plugins { - alias(libs.plugins.flipcash.android.library) -} - -android { - namespace = "${Gradle.codeNamespace}.libs.analytics" -} - -dependencies { - implementation(libs.bundles.hilt) - implementation(platform(libs.compose.bom)) - implementation(libs.compose.ui) -} diff --git a/libs/analytics/src/main/kotlin/com/getcode/libs/analytics/AnalyticsService.kt b/libs/analytics/src/main/kotlin/com/getcode/libs/analytics/AnalyticsService.kt deleted file mode 100644 index 662e3c0e93..0000000000 --- a/libs/analytics/src/main/kotlin/com/getcode/libs/analytics/AnalyticsService.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.getcode.libs.analytics - -import androidx.compose.runtime.ProvidableCompositionLocal -import androidx.compose.runtime.staticCompositionLocalOf - -interface AnalyticsService { - fun onAppStart() - fun onAppStarted() - fun unintentionalLogout() - fun action(action: AppAction, source: AppActionSource? = null) -} - -interface AppAction { - val value: String -} - -interface AppActionSource { - val value: String -} - -private class AnalyticsServiceNull: AnalyticsService { - override fun onAppStart() = Unit - override fun onAppStarted() = Unit - override fun unintentionalLogout() = Unit - override fun action(action: AppAction, source: AppActionSource?) = Unit -} - -val LocalAnalytics: ProvidableCompositionLocal = staticCompositionLocalOf { AnalyticsServiceNull() } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 0fa147ef21..f40322ba1d 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -195,7 +195,6 @@ include( ":apps:flipcash:features:tipping", // Internal libs - ":libs:analytics", ":libs:analytics-events", ":libs:biometrics", ":libs:code-detection",