From 02403979857a9e79f2eee30bca7937f18be785aa Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 10:36:18 -0400 Subject: [PATCH 01/20] refactor(analytics): add the track interface beside the legacy service --- .../shared/analytics/build.gradle.kts | 1 + .../com/flipcash/app/analytics/Analytics.kt | 4 +- .../app/analytics/FlipcashAnalytics.kt | 22 ++++++ .../app/analytics/RecordingAnalytics.kt | 12 +++ .../app/analytics/inject/AnalyticsModule.kt | 4 + .../internal/MixpanelAnalyticsDelegate.kt | 48 ++++++++++++ .../app/analytics/MixpanelTrackTest.kt | 78 +++++++++++++++++++ 7 files changed, 168 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/FlipcashAnalytics.kt create mode 100644 apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt create mode 100644 apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/MixpanelTrackTest.kt diff --git a/apps/flipcash/shared/analytics/build.gradle.kts b/apps/flipcash/shared/analytics/build.gradle.kts index bd70f1bf4e..0b2d1cb1b5 100644 --- a/apps/flipcash/shared/analytics/build.gradle.kts +++ b/apps/flipcash/shared/analytics/build.gradle.kts @@ -17,6 +17,7 @@ dependencies { 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/Analytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt index 43cc0e10b6..a582e94346 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 @@ -15,7 +15,7 @@ import com.getcode.opencode.model.financial.Fiat import com.getcode.opencode.model.financial.LocalFiat import com.getcode.solana.keys.Mint -interface FlipcashAnalyticsService : AnalyticsService { +interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { 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) @@ -115,6 +115,8 @@ object Analytics { } class StubFlipcashAnalytics : FlipcashAnalyticsService { + override fun track(event: com.flipcash.analytics.AnalyticsEvent) = Unit + override fun increment(counter: com.flipcash.analytics.PeopleCounter, amount: Double) = Unit override fun onAppStart() = Unit override fun onAppStarted() = Unit override fun unintentionalLogout() = Unit 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/RecordingAnalytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt new file mode 100644 index 0000000000..9d211d1767 --- /dev/null +++ b/apps/flipcash/shared/analytics/src/main/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 : FlipcashAnalyticsService by StubFlipcashAnalytics() { + 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/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..e7a1cdaa75 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,5 +1,6 @@ package com.flipcash.app.analytics.inject +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.TokenSymbolResolver import com.flipcash.app.analytics.internal.MixpanelAnalyticsDelegate @@ -17,4 +18,7 @@ object AnalyticsModule { mixpanelAPI: MixpanelAPI, tokenSymbolResolver: TokenSymbolResolver, ): FlipcashAnalyticsService = MixpanelAnalyticsDelegate(mixpanelAPI, tokenSymbolResolver) + + @Provides + fun providesFlipcashAnalytics(service: FlipcashAnalyticsService): FlipcashAnalytics = service } 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..f761b5e2a0 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,5 +1,7 @@ package com.flipcash.app.analytics.internal +import com.flipcash.analytics.PeopleCounter +import com.flipcash.analytics.PropertyValue import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.AnalyticsEvent import com.flipcash.app.analytics.FlipcashAnalyticsService @@ -57,6 +59,28 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( override fun unintentionalLogout() = Unit + override fun track(event: com.flipcash.analytics.AnalyticsEvent) { + val properties = event.toMixpanelProperties(tokenSymbolResolver) + + 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 + } + + mixpanelAPI.track(event.name, JSONObject(properties)) + } + + override fun increment(counter: PeopleCounter, amount: Double) { + increment(counter.key, amount) + } + override fun action(action: AppAction, source: AppActionSource?) { track(name = action.value) } @@ -350,6 +374,30 @@ private val MINT_PROPERTIES = mapOf( "Payment Mint" to "Payment Token Symbol", ) +/** + * Converts [this] event's properties to the values Mixpanel's JSON takes, with a ticker + * added beside every mint the [resolver] knows (see [withTokenSymbols]). + */ +internal fun com.flipcash.analytics.AnalyticsEvent.toMixpanelProperties( + resolver: TokenSymbolResolver, +): 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) } + } +} + /** * Returns [properties] with a ticker added beside every mint the [resolver] knows. * 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"]) + } +} From 9fdc2a624a0a4538b51f4e2bc3ce7ab76c82ac70 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 10:38:23 -0400 Subject: [PATCH 02/20] refactor(analytics): convert Android domain types for the shared builders --- .../shared/analytics/build.gradle.kts | 1 + .../com/flipcash/app/analytics/Conversions.kt | 97 +++++++++++++++++++ .../flipcash/app/analytics/ConversionsTest.kt | 92 ++++++++++++++++++ 3 files changed, 190 insertions(+) create mode 100644 apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Conversions.kt create mode 100644 apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/ConversionsTest.kt diff --git a/apps/flipcash/shared/analytics/build.gradle.kts b/apps/flipcash/shared/analytics/build.gradle.kts index 0b2d1cb1b5..0437b7414d 100644 --- a/apps/flipcash/shared/analytics/build.gradle.kts +++ b/apps/flipcash/shared/analytics/build.gradle.kts @@ -9,6 +9,7 @@ android { dependencies { testImplementation(kotlin("test")) testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) 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/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(), + ) + } +} From 8b536afc8c16bb2c62238526e25c55cea67f77e6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 11:01:05 -0400 Subject: [PATCH 03/20] refactor(analytics): move chat events onto the shared builders --- .../app/messenger/internal/ChatViewModel.kt | 6 ++- .../com/flipcash/app/analytics/Analytics.kt | 21 -------- .../com/flipcash/app/analytics/Events.kt | 51 ++----------------- .../internal/MixpanelAnalyticsDelegate.kt | 17 ------- .../internal/delegates/EventStreamDelegate.kt | 8 +-- .../internal/delegates/MessagingDelegate.kt | 7 ++- .../shared/chat/ReceivedCounterTest.kt | 32 ++++++------ .../flipcash/shared/chat/ReceivedEventTest.kt | 31 +++++++---- 8 files changed, 52 insertions(+), 121 deletions(-) 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..b1b3efc9bc 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,10 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap +import com.flipcash.analytics.events.ChatEvents import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -1838,11 +1840,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)) } } } 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 a582e94346..260926d392 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 @@ -4,7 +4,6 @@ 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 @@ -37,7 +36,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { 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. */ @@ -57,12 +55,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { /** @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) } @@ -70,15 +62,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { 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 @@ -148,7 +131,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { 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 @@ -160,9 +142,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { 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 } @Composable 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 index 4b41386dac..6048de5560 100644 --- 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 @@ -4,7 +4,6 @@ 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 @@ -158,38 +157,9 @@ internal sealed interface AnalyticsEvent { 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) - } - } + // Marker only: `SentMessage`/`TipReceived`/`MessageReceived` moved to the shared + // `ChatEvents` builders in `:libs:analytics-events`. `SentCash` is its one remaining member. + sealed interface ChatEvent : AnalyticsEvent /** * The gallery scan path, end to end. @@ -450,14 +420,6 @@ internal val Analytics.AddMoneyMethod.propertyValue: String 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()) @@ -497,10 +459,3 @@ internal val DisplayNameSource.propertyValue: String 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/internal/MixpanelAnalyticsDelegate.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/internal/MixpanelAnalyticsDelegate.kt index f761b5e2a0..83619a50e5 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 @@ -12,7 +12,6 @@ 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 @@ -267,10 +266,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( 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) } @@ -316,18 +311,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( 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)) - } - - override fun messageReceived(chatType: ChatType) { - track(AnalyticsEvent.ChatEvent.MessageReceived(chatType)) - } - // region Internal private fun track(event: AnalyticsEvent, vararg extra: Pair) { 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..a6f91ac9ed 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,6 +1,6 @@ package com.flipcash.shared.chat.internal.delegates -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.PeopleCounter import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource @@ -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..269c28338c 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.analytics.events.ChatEvents import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -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) } } From fe474f9514a0a6abed3e190883608b19b90cb3a5 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 11:37:02 -0400 Subject: [PATCH 04/20] refactor(analytics): move transfer events onto the shared builders Every transfer call site now tracks a TransferEvents builder. The amount conversion follows the type each caller already had (LocalFiat, or the scanned Fiat on a failed grab), so the property block is unchanged. Grab Time now goes out as a number instead of a string. The session delegate tests assert the event each grab, give, claim and cash-link share sends. TippingCoordinator lost its transfer call in #1463; only its unused Analytics import goes here. --- .../app/messenger/internal/ChatViewModel.kt | 25 +++--- .../app/withdrawal/WithdrawalViewModel.kt | 28 +++---- .../com/flipcash/app/analytics/Analytics.kt | 25 ------ .../com/flipcash/app/analytics/Events.kt | 69 ---------------- .../internal/MixpanelAnalyticsDelegate.kt | 34 -------- .../delegates/BillPresentationDelegate.kt | 15 ++-- .../internal/delegates/CashLinkDelegate.kt | 13 ++- .../internal/delegates/CodeScanDelegate.kt | 15 ++-- .../delegates/GiftCardSharingDelegate.kt | 14 ++-- .../delegates/BillPresentationDelegateTest.kt | 82 ++++++++++++++++++- .../delegates/CashLinkDelegateTest.kt | 30 ++++++- .../delegates/CodeScanDelegateTest.kt | 73 ++++++++++++++++- .../delegates/GiftCardSharingDelegateTest.kt | 74 ++++++++++++++++- .../shared/tipping/TippingCoordinator.kt | 1 - 14 files changed, 305 insertions(+), 193 deletions(-) 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 b1b3efc9bc..8c2dc3320f 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,7 +13,9 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap +import com.flipcash.analytics.State as AnalyticsState import com.flipcash.analytics.events.ChatEvents +import com.flipcash.analytics.events.TransferEvents import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.analytics @@ -2019,16 +2021,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, @@ -2036,10 +2039,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/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..9f261eb6f1 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.analytics import com.flipcash.app.core.extensions.onResult import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.app.userflags.UserFlagsCoordinator @@ -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( 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 260926d392..14b82ce93f 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 @@ -11,13 +11,9 @@ 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 interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { - 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) @@ -62,24 +58,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { object Analytics { - 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 } @@ -105,9 +83,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { 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 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 index 6048de5560..11e0b43869 100644 --- 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 @@ -105,62 +105,6 @@ internal sealed interface AnalyticsEvent { } } - 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" - } - - // Marker only: `SentMessage`/`TipReceived`/`MessageReceived` moved to the shared - // `ChatEvents` builders in `:libs:analytics-events`. `SentCash` is its one remaining member. - sealed interface ChatEvent : AnalyticsEvent - /** * The gallery scan path, end to end. * @@ -440,19 +384,6 @@ internal fun Fiat.asProperties(): Map { } } -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" 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 83619a50e5..71fe587588 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 @@ -8,7 +8,6 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService 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 @@ -17,7 +16,6 @@ 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 @@ -84,38 +82,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( 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)) } 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..736ee6ee0e 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.analytics.State +import com.flipcash.analytics.events.TransferEvents import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -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..8989b33d3c 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,9 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.State +import com.flipcash.analytics.events.TransferEvents import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -143,7 +145,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 +159,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..b2fcca72e3 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,9 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.State +import com.flipcash.analytics.events.TransferEvents import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -138,7 +140,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 +163,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) 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..e9c5448ab0 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.analytics.CashLinkChoice +import com.flipcash.analytics.State +import com.flipcash.analytics.events.TransferEvents import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -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/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..f584c43dc8 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,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.internal.bill.BillController import com.flipcash.app.session.SettledClaim @@ -10,11 +13,16 @@ 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 import io.mockk.mockk import io.mockk.slot +import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -35,12 +43,20 @@ class CashLinkDelegateTest { private val billController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + // A spy so these tests can still verify `deeplinkRouted`, which moves in its own commit. + private val analytics = spyk(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(), @@ -206,10 +222,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 +256,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..5bb2f55181 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,10 @@ 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.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 +17,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 @@ -22,6 +30,7 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject import io.mockk.slot +import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -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,8 @@ class CodeScanDelegateTest { private val billController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - private val analytics = mockk(relaxed = true) + // A spy so the scan tests can still verify `tipCardScanned`, which moves in its own commit. + private val analytics = spyk(RecordingAnalytics()) private val vibrator = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) private val walletReveal = mockk(relaxed = true) @@ -50,6 +61,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 +278,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 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/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..3b682955a3 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,6 +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 From 92a4a64c03cfca6a75a4d84aec70137a3bd55dc1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 11:50:29 -0400 Subject: [PATCH 05/20] refactor(analytics): move add money events onto the shared builders Every add-money call site now tracks an AddMoneyEvents builder instead of the legacy Analytics.AddMoney enums and FlipcashAnalyticsService methods. Fiat/LocalFiat amounts and thrown errors go through their .analytics converters, so each property block is unchanged except numbers going out as numbers instead of strings. Analytics.AddMoneySource, AddMoneyMethod, the AddMoneyEvent sealed interface, and the Mixpanel delegate overrides are dead now that every caller uses the shared builder, so they are removed. --- .../app/balance/internal/WalletViewModel.kt | 5 +- .../balance/internal/BalanceViewModelTest.kt | 9 +- .../app/cash/internal/CashScreenViewModel.kt | 5 +- .../cash/internal/CashScreenViewModelTest.kt | 30 ++++++- .../app/deposit/internal/DepositViewModel.kt | 3 +- .../app/menu/internal/MenuScreenViewModel.kt | 9 +- .../app/messenger/internal/ChatViewModel.kt | 5 +- .../com/flipcash/app/analytics/Analytics.kt | 15 ---- .../com/flipcash/app/analytics/Events.kt | 63 -------------- .../internal/MixpanelAnalyticsDelegate.kt | 34 -------- .../InternalPurchaseMethodController.kt | 11 +-- .../internal/delegates/DepositDelegate.kt | 5 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 83 ++++++++++++------- .../app/tokens/ui/SwapViewModelErrorTest.kt | 48 ++++++++++- 14 files changed, 159 insertions(+), 166 deletions(-) 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..47f498a313 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,7 +1,8 @@ package com.flipcash.app.balance.internal import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.ui.onboarding.TutorialItem import com.flipcash.app.core.AppRoute @@ -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) 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/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..cd4a18beaf 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,7 +1,8 @@ package com.flipcash.app.cash.internal import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.core.bill.Scannable @@ -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( 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/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..729db50a35 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,6 +2,7 @@ package com.flipcash.app.deposit.internal import android.content.ClipboardManager import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.setText @@ -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 { 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..6102b23efb 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,7 +2,8 @@ package com.flipcash.app.menu.internal import android.content.ClipboardManager import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.bills.share.TipCodePreviewCache import com.flipcash.app.core.AppRoute @@ -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 ) ) }, 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 8c2dc3320f..f937c6bd00 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,10 +13,11 @@ import androidx.lifecycle.viewModelScope import androidx.paging.PagingData import androidx.paging.cachedIn import androidx.paging.flatMap +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.Analytics import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.analytics import com.flipcash.app.contacts.ContactCoordinator @@ -1915,7 +1916,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)) } 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 14b82ce93f..3afcea630c 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 @@ -18,12 +18,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { 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) @@ -59,8 +53,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { object Analytics { 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 } @@ -89,13 +81,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { 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 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 index 11e0b43869..9f7dccd9b5 100644 --- 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 @@ -246,50 +246,6 @@ internal sealed interface AnalyticsEvent { } } - 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()) @@ -345,25 +301,6 @@ internal sealed interface AnalyticsEvent { } } -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 fun LocalFiat.asProperties(): Map { return buildMap { putAll(underlyingTokenAmount.asProperties()) 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 71fe587588..0634fd5772 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 @@ -134,40 +134,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( 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)) } 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..df84d1f0e9 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,6 +1,7 @@ package com.flipcash.app.funding.internal -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.AddMoneyMethod +import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.core.tokens.FundingSource @@ -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 } 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..86d884cd2e 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,6 +1,7 @@ package com.flipcash.app.session.internal.delegates -import com.flipcash.app.analytics.Analytics +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.events.AddMoneyEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.AppRoute import com.flipcash.app.funding.PurchaseMethodController @@ -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/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..26bcbe4c8c 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,15 @@ package com.flipcash.app.tokens.ui import androidx.lifecycle.viewModelScope +import com.flipcash.analytics.AddMoneyMethod +import com.flipcash.analytics.AddMoneySource +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.AddMoneyEvents 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.app.analytics.analytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.extensions.onResult import com.flipcash.app.core.extensions.to @@ -466,13 +471,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 +910,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 +1116,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 +1132,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 +1157,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 +1567,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()) } @@ -1656,7 +1671,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 +1753,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 +1769,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( @@ -1832,7 +1851,9 @@ class SwapViewModel @Inject constructor( amount.localFiat.underlyingTokenAmount ) addMoneyMethod?.let { method -> - analytics.addMoneyPaymentInvoked(method, amount.localFiat.nativeAmount) + analytics.track( + AddMoneyEvents.paymentInvoked(method, amount.localFiat.nativeAmount.analytics) + ) } }, ).onSuccess { result -> @@ -1865,7 +1886,9 @@ class SwapViewModel @Inject constructor( analytics.walletTransactionFailed(OnRampProvider.Phantom) } addMoneyMethod?.let { method -> - analytics.addMoney(method, successful = false, error = deeplinkError) + analytics.track( + AddMoneyEvents.result(method, AnalyticsState.FAILURE, null, deeplinkError.analytics) + ) } } 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..3af92e77c3 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,7 +1,11 @@ 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.PropertyValue +import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.app.analytics.RecordingAnalytics import com.flipcash.app.core.tokens.SwapPurpose import com.flipcash.app.core.tokens.FundingSource import com.flipcash.app.onramp.CoinbaseOnRampController @@ -56,7 +60,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 +80,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) @@ -216,6 +222,44 @@ class SwapViewModelErrorTest { verify(exactly = 0) { usdcDepositSweep.execute(any()) } } + @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 From e048268a1abb1698546401c45750afc0ad54d6c9 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 12:02:34 -0400 Subject: [PATCH 06/20] refactor(analytics): move onramp events onto the shared builders The five contact-verification screens now track OnrampEvents.step(...) from the same LaunchedEffect. The events carry no properties, and a new test pins each step to the name the screens sent before. openOnramp, onrampPurchase, OnrampSource, OnrampPurchaseStep and their eight events had no callers and are deleted. --- .../email/EmailMagicLinkScreen.kt | 5 +- .../email/EmailVerificationScreen.kt | 5 +- .../internal/VerificationIntroScreen.kt | 5 +- .../verification/phone/PhoneCodeScreen.kt | 5 +- .../phone/PhoneVerificationScreen.kt | 5 +- .../verification/OnrampStepEventTest.kt | 35 ++++++++++++++ .../com/flipcash/app/analytics/Analytics.kt | 10 ---- .../com/flipcash/app/analytics/Events.kt | 34 ------------- .../internal/MixpanelAnalyticsDelegate.kt | 48 ------------------- 9 files changed, 50 insertions(+), 102 deletions(-) create mode 100644 apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/OnrampStepEventTest.kt 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/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/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 3afcea630c..0844335bcf 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 @@ -15,9 +15,6 @@ import com.getcode.solana.keys.Mint interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { 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 connectWallet(provider: OnRampProvider.UsesDeeplinks) fun amountSelectedForWalletTransfer(provider: OnRampProvider.UsesDeeplinks, amount: Fiat) fun transactionSubmittedToWallet(provider: OnRampProvider.UsesDeeplinks) @@ -52,9 +49,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { object Analytics { - enum class OnrampSource { Settings, Balance, Give } - 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 { @@ -77,10 +71,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { 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 connectWallet(provider: OnRampProvider.UsesDeeplinks) = Unit override fun amountSelectedForWalletTransfer(provider: OnRampProvider.UsesDeeplinks, amount: Fiat) = Unit override fun transactionSubmittedToWallet(provider: OnRampProvider.UsesDeeplinks) = Unit 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 index 9f7dccd9b5..3fcb83ea16 100644 --- 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 @@ -212,40 +212,6 @@ internal sealed interface AnalyticsEvent { 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 OpenTokenInfoEvent : AnalyticsEvent { val mint: Mint override fun toProperties() = mapOf("Mint" to mint.base58()) 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 0634fd5772..6a4b5d75ff 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 @@ -86,54 +86,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( 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 connectWallet(provider: OnRampProvider.UsesDeeplinks) { track(AnalyticsEvent.WalletConnect(provider)) } From ef0d7dbadf3d3b73f3375f2b2d0c52f8ee91e255 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 12:15:03 -0400 Subject: [PATCH 07/20] refactor(analytics): move wallet events onto the shared builders Wallet connect/request-amount/failed/cancel in SwapViewModel now go through WalletEvents instead of the bespoke FlipcashAnalyticsService methods, so amounts and providers convert through .analytics like the other builder call sites. transactionSubmittedToWallet had no caller and is deleted along with its event and property mapping. --- .../com/flipcash/app/analytics/Analytics.kt | 12 -- .../com/flipcash/app/analytics/Events.kt | 39 ------ .../internal/MixpanelAnalyticsDelegate.kt | 24 ---- .../flipcash/app/tokens/ui/SwapViewModel.kt | 15 ++- .../app/tokens/ui/SwapViewModelErrorTest.kt | 115 ++++++++++++++++++ 5 files changed, 124 insertions(+), 81 deletions(-) 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 0844335bcf..67ea864fef 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 @@ -3,7 +3,6 @@ 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.getcode.ed25519.Ed25519.KeyPair import com.getcode.libs.analytics.AnalyticsService import com.getcode.libs.analytics.AppAction @@ -15,11 +14,6 @@ import com.getcode.solana.keys.Mint interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) - 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) @@ -71,12 +65,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) = 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 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 index 3fcb83ea16..25761d1d8d 100644 --- 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 @@ -3,7 +3,6 @@ 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.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode @@ -174,44 +173,6 @@ internal sealed interface AnalyticsEvent { 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 OpenTokenInfoEvent : AnalyticsEvent { val mint: Mint override fun toProperties() = mapOf("Mint" to mint.base58()) 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 6a4b5d75ff..6a77a42cee 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 @@ -10,7 +10,6 @@ import com.flipcash.app.analytics.asProperties import com.flipcash.app.analytics.propertyValue import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.navigation.DeeplinkType -import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.libs.analytics.AppAction import com.getcode.libs.analytics.AppActionSource @@ -86,29 +85,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(AnalyticsEvent.PaidForAccount(price, currency, owner)) } - 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) 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 26bcbe4c8c..c29ceb27c0 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 @@ -5,6 +5,7 @@ import com.flipcash.analytics.AddMoneyMethod import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.State as AnalyticsState import com.flipcash.analytics.events.AddMoneyEvents +import com.flipcash.analytics.events.WalletEvents import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.Button @@ -1828,7 +1829,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) @@ -1846,9 +1847,11 @@ 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.track( @@ -1880,10 +1883,10 @@ 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.track( 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 3af92e77c3..3433e3d4d1 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 @@ -4,12 +4,18 @@ import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.analytics.AddMoneyMethod import com.flipcash.analytics.PropertyValue import com.flipcash.analytics.State as AnalyticsState +import com.flipcash.analytics.WalletProvider import com.flipcash.analytics.events.AddMoneyEvents +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 @@ -19,6 +25,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 @@ -222,6 +229,114 @@ 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) From 76e9b68e6744e93ef91cb1cc267a27bfe13dd314 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 12:22:15 -0400 Subject: [PATCH 08/20] refactor(analytics): move token info events onto the shared builders TokenInfoEntry.Discovery, Chat and ChatGate used to report as "Token Info: Opened From Wallet". They now send Opened From Discovery, Chat and Chat Gate, so the Wallet count drops by what those three entries carried. Deeplink and Wallet are unchanged. The mapping has no else branch, so a new TokenInfoEntry fails to compile until it is mapped. TokenInfoSource.Give and its event have had no caller since 6419a95da and are deleted. --- .../flipcash/app/tokens/TokenInfoScreen.kt | 27 +++++--- .../tokens/TokenInfoScreenAnalyticsTest.kt | 63 +++++++++++++++++++ .../com/flipcash/app/analytics/Analytics.kt | 3 - .../com/flipcash/app/analytics/Events.kt | 15 ----- .../internal/MixpanelAnalyticsDelegate.kt | 9 --- 5 files changed, 81 insertions(+), 36 deletions(-) create mode 100644 apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/TokenInfoScreenAnalyticsTest.kt 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..8e12681dab 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,10 @@ 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.analytics.TokenInfoSource +import com.flipcash.analytics.events.TokenInfoEvents import com.flipcash.app.analytics.Button +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 @@ -172,14 +174,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 +211,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/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/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 67ea864fef..a929f3f78f 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 @@ -14,7 +14,6 @@ import com.getcode.solana.keys.Mint interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) - 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 tipCardScanned() @@ -43,7 +42,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { object Analytics { - enum class TokenInfoSource { Deeplink, Wallet, Give } enum class PurchaseMethod { Reserves, Phantom, Coinbase } sealed interface SwapMethod { enum class Buy(val with: PurchaseMethod) : SwapMethod { @@ -65,7 +63,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) = 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 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 index 25761d1d8d..89735aed7d 100644 --- 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 @@ -173,21 +173,6 @@ internal sealed interface AnalyticsEvent { override val name = "Pool: Declared Outcome" } - 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 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 6a77a42cee..54af21d198 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 @@ -85,15 +85,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(AnalyticsEvent.PaidForAccount(price, currency, owner)) } - 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, From 256afe626d4e45a944ec2bbe256c4b3d4e17734b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 12:38:56 -0400 Subject: [PATCH 09/20] refactor(analytics): move buy and sell events onto the shared builders SwapViewModel.trackTransaction now calls the generated SwapEvents.purchase and SwapEvents.sell builders instead of the ad-hoc Analytics.buy/sell path, dropping the local Analytics.SwapMethod/PurchaseMethod enums and the TokenTransactionEvent sealed hierarchy they drove. Fee is sent as a decimal (events.toml already defines it as `type = "decimal"`) instead of a string. --- .../com/flipcash/app/analytics/Analytics.kt | 20 -------- .../com/flipcash/app/analytics/Events.kt | 39 --------------- .../internal/MixpanelAnalyticsDelegate.kt | 35 -------------- .../flipcash/app/tokens/ui/SwapViewModel.kt | 47 ++++++++++--------- .../app/tokens/ui/SwapViewModelErrorTest.kt | 42 ++++++++++++++++- 5 files changed, 66 insertions(+), 117 deletions(-) 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 a929f3f78f..9efc2144e1 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 @@ -9,13 +9,9 @@ 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.solana.keys.Mint interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) - fun buy(method: Analytics.PurchaseMethod, mint: Mint, amount: Fiat, error: Throwable? = null) - fun sell(mint: Mint, amount: Fiat, feeAmount: Fiat, error: Throwable? = null) fun tipCardScanned() /** An image was picked from the gallery and the still-image search started. */ @@ -40,19 +36,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { } } -object Analytics { - - 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 track(event: com.flipcash.analytics.AnalyticsEvent) = Unit override fun increment(counter: com.flipcash.analytics.PeopleCounter, amount: Double) = Unit @@ -63,9 +46,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) = 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 tipCardScanned() = Unit override fun galleryImagePicked() = Unit override fun galleryScanSucceeded(tier: Int, zoom: Float, timeMillis: Long) = Unit 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 index 89735aed7d..30abe5a62b 100644 --- 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 @@ -8,7 +8,6 @@ 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 @@ -173,44 +172,6 @@ internal sealed interface AnalyticsEvent { override val name = "Pool: Declared Outcome" } - 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 fun LocalFiat.asProperties(): Map { 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 54af21d198..7892af07db 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 @@ -2,7 +2,6 @@ package com.flipcash.app.analytics.internal import com.flipcash.analytics.PeopleCounter import com.flipcash.analytics.PropertyValue -import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.AnalyticsEvent import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.TokenSymbolResolver @@ -14,9 +13,7 @@ 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.services.flipcash.BuildConfig -import com.getcode.solana.keys.Mint import com.getcode.utils.TraceType import com.getcode.utils.trace import com.google.firebase.Firebase @@ -85,38 +82,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(AnalyticsEvent.PaidForAccount(price, currency, owner)) } - 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 - ) - } - track(event) - } - - override fun sell(mint: Mint, amount: Fiat, feeAmount: Fiat, error: Throwable?) { - track(AnalyticsEvent.TokenTransactionEvent.Sell(mint, amount, feeAmount, error)) - } - override fun tipCardScanned() { track(AnalyticsEvent.TipCardEvent.Scanned) } 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 c29ceb27c0..8bd09dcfec 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 @@ -3,11 +3,12 @@ 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.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.app.analytics.analytics @@ -1789,34 +1790,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, + ) + ) } } 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 3433e3d4d1..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 @@ -2,10 +2,13 @@ package com.flipcash.app.tokens.ui import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator 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 @@ -35,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 @@ -159,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 } @@ -168,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 @@ -197,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 From 692a5546e9efbba39e5fd126e379031338cd0fb7 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 12:46:37 -0400 Subject: [PATCH 10/20] refactor(analytics): move scan events onto the shared builders Gallery scan (Scanner.kt), tip card scan (CodeScanDelegate) and tip card present (TipCardDelegate) now call analytics.track(ScanEvents.*) instead of the FlipcashAnalyticsService methods, which are deleted along with their Events.kt classes and MixpanelAnalyticsDelegate wiring. Tier, Zoom and Time go out as numbers and Exhausted as a flag, matching the shared builder; Scanner.kt has no test harness so its three call sites are reviewed by diff only. CodeScanDelegateTest and TipCardDelegateTest now assert against RecordingAnalytics with ScanEvents builders instead of verifying the legacy methods. --- .../flipcash/app/scanner/internal/Scanner.kt | 21 ++++---- .../com/flipcash/app/analytics/Analytics.kt | 17 ------- .../com/flipcash/app/analytics/Events.kt | 48 ------------------- .../internal/MixpanelAnalyticsDelegate.kt | 20 -------- .../internal/delegates/CodeScanDelegate.kt | 3 +- .../internal/delegates/TipCardDelegate.kt | 5 +- .../delegates/CodeScanDelegateTest.kt | 9 ++-- .../internal/delegates/TipCardDelegateTest.kt | 22 ++++++++- 8 files changed, 42 insertions(+), 103 deletions(-) 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..e6921c3d60 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,6 +22,7 @@ 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.ScanEvents import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.AppRoute.Token.* @@ -164,17 +165,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 +196,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/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 9efc2144e1..275c8d2b2f 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 @@ -12,17 +12,6 @@ import com.getcode.opencode.model.financial.CurrencyCode interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) - 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) @@ -46,12 +35,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) = 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 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 index 30abe5a62b..be50370919 100644 --- 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 @@ -103,54 +103,6 @@ internal sealed interface AnalyticsEvent { } } - /** - * 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) 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 7892af07db..27bade3fde 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 @@ -82,26 +82,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(AnalyticsEvent.PaidForAccount(price, currency, owner)) } - 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)) } 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 b2fcca72e3..207d56e682 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,6 +1,7 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.analytics.State +import com.flipcash.analytics.events.ScanEvents import com.flipcash.analytics.events.TransferEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.analytics @@ -190,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/TipCardDelegate.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/delegates/TipCardDelegate.kt index 7d9daec32f..431a6952ba 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,5 +1,6 @@ package com.flipcash.app.session.internal.delegates +import com.flipcash.analytics.events.ScanEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.tipping.OwnTipCard @@ -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/delegates/CodeScanDelegateTest.kt b/apps/flipcash/shared/session/src/test/kotlin/com/flipcash/app/session/internal/delegates/CodeScanDelegateTest.kt index 5bb2f55181..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 @@ -2,6 +2,7 @@ package com.flipcash.app.session.internal.delegates 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 @@ -30,7 +31,6 @@ import io.mockk.mockk import io.mockk.mockkObject import io.mockk.unmockkObject import io.mockk.slot -import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.UnconfinedTestDispatcher @@ -52,8 +52,7 @@ class CodeScanDelegateTest { private val billController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - // A spy so the scan tests can still verify `tipCardScanned`, which moves in its own commit. - private val analytics = spyk(RecordingAnalytics()) + private val analytics = RecordingAnalytics() private val vibrator = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) private val walletReveal = mockk(relaxed = true) @@ -376,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 @@ -390,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/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) } } From f92acd9359681d118b73c8b73bb7389253c3c101 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 12:54:16 -0400 Subject: [PATCH 11/20] refactor(analytics): move deeplink events onto the shared builders App.kt, Scanner.kt and CashLinkDelegate.kt track the DeeplinkEvents builders instead of the legacy service methods. Two values change: - Type comes from DeeplinkType.analytics, an explicit string per subclass, instead of javaClass.simpleName. The strings match the source class names, but no keep rule covers DeeplinkType, so a minified release build may have sent R8-renamed names until now. - The parse-failure Error carries the URL without its query or fragment, which can hold secrets. CashLinkDelegateTest asserts the routed event, with the type as a string literal, on each early return and on success. --- .../com/flipcash/app/internal/ui/App.kt | 4 +- .../flipcash/app/scanner/internal/Scanner.kt | 9 +++- .../com/flipcash/app/analytics/Analytics.kt | 7 --- .../com/flipcash/app/analytics/Events.kt | 46 ------------------- .../internal/MixpanelAnalyticsDelegate.kt | 13 ------ .../internal/delegates/CashLinkDelegate.kt | 27 +++++++---- .../delegates/CashLinkDelegateTest.kt | 23 ++++++---- 7 files changed, 42 insertions(+), 87 deletions(-) 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/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 e6921c3d60..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,8 +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 @@ -151,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 } } 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 275c8d2b2f..6526665037 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 @@ -2,7 +2,6 @@ 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.getcode.ed25519.Ed25519.KeyPair import com.getcode.libs.analytics.AnalyticsService import com.getcode.libs.analytics.AppAction @@ -12,9 +11,6 @@ import com.getcode.opencode.model.financial.CurrencyCode interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) - 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. */ @@ -35,9 +31,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) = 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 } 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 index be50370919..6004a923d8 100644 --- 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 @@ -1,8 +1,6 @@ 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.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode @@ -59,50 +57,6 @@ internal sealed interface AnalyticsEvent { } } - 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 PoolEvent : AnalyticsEvent { val id: ID override fun toProperties() = mapOf("ID" to id.base58) 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 27bade3fde..75b4eb3209 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 @@ -8,7 +8,6 @@ import com.flipcash.app.analytics.TokenSymbolResolver import com.flipcash.app.analytics.asProperties import com.flipcash.app.analytics.propertyValue import com.flipcash.app.core.DisplayNameSource -import com.flipcash.app.core.navigation.DeeplinkType import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.libs.analytics.AppAction import com.getcode.libs.analytics.AppActionSource @@ -82,18 +81,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(AnalyticsEvent.PaidForAccount(price, currency, owner)) } - 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)) } 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 8989b33d3c..33486ae31f 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,6 +1,7 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.analytics.State +import com.flipcash.analytics.events.DeeplinkEvents import com.flipcash.analytics.events.TransferEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.analytics @@ -85,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 } @@ -99,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 } @@ -112,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) } } 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 f584c43dc8..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,7 @@ package com.flipcash.app.session.internal.delegates 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 @@ -22,7 +23,6 @@ import com.getcode.util.resources.ResourceHelper import io.mockk.every import io.mockk.mockk import io.mockk.slot -import io.mockk.spyk import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.launch @@ -43,8 +43,7 @@ class CashLinkDelegateTest { private val billController = mockk(relaxed = true) private val userManager = mockk(relaxed = true) - // A spy so these tests can still verify `deeplinkRouted`, which moves in its own commit. - private val analytics = spyk(RecordingAnalytics()) + private val analytics = RecordingAnalytics() private val resources = mockk(relaxed = true) private val tokenCoordinator = mockk(relaxed = true) @@ -93,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 @@ -146,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 @@ -165,6 +166,10 @@ class CashLinkDelegateTest { onError = any(), ) } + assertEquals( + DeeplinkEvents.routed("CashLink", error = null), + analytics.events.single { it.name == "Deeplink: Routed" }, + ) } @Test From d956e4efe6e748b7b100f013eff3c3f52b063cec Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 13:14:52 -0400 Subject: [PATCH 12/20] refactor(analytics): move display name events onto the shared builders Replaces FlipcashAnalyticsService.displayNameSubmitted(source, hadPreviousName) with DisplayNameEvents.set/updated from the generated builder, called directly from NameEntryViewModel. Deletes the old DisplayNameEvent.Set/Updated sealed classes from Events.kt, the interface/stub method from Analytics.kt, and the Mixpanel override, since nothing else referenced them. NameEntryViewModel now takes a DispatcherProvider (wired to BaseViewModel's defaultDispatcher) so its CheckName pipeline runs deterministically under a test dispatcher, matching WalletViewModel's existing pattern; needed to cover the branch through NameEntryViewModelTest with RecordingAnalytics. --- .../features/user-profile/build.gradle.kts | 1 + .../internal/name/NameEntryViewModel.kt | 14 ++- .../internal/name/NameEntryViewModelTest.kt | 104 ++++++++++++++++++ .../com/flipcash/app/analytics/Analytics.kt | 5 - .../com/flipcash/app/analytics/Events.kt | 23 ---- .../internal/MixpanelAnalyticsDelegate.kt | 11 -- 6 files changed, 117 insertions(+), 41 deletions(-) create mode 100644 apps/flipcash/features/user-profile/src/test/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryViewModelTest.kt diff --git a/apps/flipcash/features/user-profile/build.gradle.kts b/apps/flipcash/features/user-profile/build.gradle.kts index e4a83cc55c..f5f3419c9c 100644 --- a/apps/flipcash/features/user-profile/build.gradle.kts +++ b/apps/flipcash/features/user-profile/build.gradle.kts @@ -22,5 +22,6 @@ dependencies { 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..75d646106c 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.analytics.events.DisplayNameEvents import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -39,9 +42,11 @@ class NameEntryViewModel @Inject constructor( 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( 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/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 6526665037..e816c7333d 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,7 +1,6 @@ package com.flipcash.app.analytics import androidx.compose.runtime.Composable -import com.flipcash.app.core.DisplayNameSource import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.libs.analytics.AnalyticsService import com.getcode.libs.analytics.AppAction @@ -13,9 +12,6 @@ interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) 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) - fun buttonTapped(button: Button) { action(button) } @@ -32,7 +28,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) = Unit override fun displayedErrorModal(title: String, message: String, screen: String?, callSite: String?) = Unit - override fun displayNameSubmitted(source: DisplayNameSource, hadPreviousName: Boolean) = Unit } @Composable 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 index 6004a923d8..ddde40f47e 100644 --- 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 @@ -1,6 +1,5 @@ package com.flipcash.app.analytics -import com.flipcash.app.core.DisplayNameSource import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.model.core.ID import com.getcode.opencode.model.financial.CurrencyCode @@ -14,21 +13,6 @@ 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, @@ -99,10 +83,3 @@ internal fun Fiat.asProperties(): Map { put("Quarks", quarks.toDouble().toString()) } } - -internal val DisplayNameSource.propertyValue: String - get() = when (this) { - DisplayNameSource.Onboarding -> "Onboarding" - DisplayNameSource.MyAccount -> "My Account" - DisplayNameSource.TipCardSetup -> "Tip Card Setup" - } 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 75b4eb3209..90b20c2783 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 @@ -6,8 +6,6 @@ import com.flipcash.app.analytics.AnalyticsEvent import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.TokenSymbolResolver import com.flipcash.app.analytics.asProperties -import com.flipcash.app.analytics.propertyValue -import com.flipcash.app.core.DisplayNameSource import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.libs.analytics.AppAction import com.getcode.libs.analytics.AppActionSource @@ -85,15 +83,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( 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) - } - // region Internal private fun track(event: AnalyticsEvent, vararg extra: Pair) { From df97cdfe0a383c9c83972ff4c96f93b4999d6716 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 13:19:33 -0400 Subject: [PATCH 13/20] refactor(analytics): move error modal events onto the shared builders NavMessagingEntryDecorator.kt tracks ErrorModalEvents.displayed instead of the legacy displayedErrorModal method. Name, keys (Title, Message, Screen, Call Site) and value types are unchanged; Screen and Call Site still drop from the map when null, matching :libs:analytics-events' ErrorModalEventsTest, which already pins that exact name and property map. The decorator has no test harness, so the call site was reviewed by diff against that existing builder test rather than given a new one. Deletes displayedErrorModal from FlipcashAnalyticsService, StubFlipcashAnalytics and MixpanelAnalyticsDelegate, and ErrorModalDisplayed from Events.kt. No dead declarations in this domain. --- .../decorators/NavMessagingEntryDecorator.kt | 13 ++++++++----- .../com/flipcash/app/analytics/Analytics.kt | 3 --- .../kotlin/com/flipcash/app/analytics/Events.kt | 15 --------------- .../internal/MixpanelAnalyticsDelegate.kt | 4 ---- 4 files changed, 8 insertions(+), 27 deletions(-) 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/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 e816c7333d..2ade9429ce 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 @@ -10,7 +10,6 @@ import com.getcode.opencode.model.financial.CurrencyCode interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) - fun displayedErrorModal(title: String, message: String, screen: String? = null, callSite: String? = null) fun buttonTapped(button: Button) { action(button) @@ -26,8 +25,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun action(action: AppAction, source: AppActionSource?) = Unit override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) = Unit - - override fun displayedErrorModal(title: String, message: String, screen: String?, callSite: String?) = Unit } @Composable 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 index ddde40f47e..79e6bae56c 100644 --- 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 @@ -26,21 +26,6 @@ internal sealed interface AnalyticsEvent { ) } - 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 PoolEvent : AnalyticsEvent { val id: ID override fun toProperties() = mapOf("ID" to id.base58) 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 90b20c2783..1389fa272c 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 @@ -79,10 +79,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(AnalyticsEvent.PaidForAccount(price, currency, owner)) } - override fun displayedErrorModal(title: String, message: String, screen: String?, callSite: String?) { - track(AnalyticsEvent.ErrorModalDisplayed(title, message, screen, callSite)) - } - // region Internal private fun track(event: AnalyticsEvent, vararg extra: Pair) { From 2b275b1305fa9e00f476133b2b3259b29ae22c21 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 14:24:51 -0400 Subject: [PATCH 14/20] refactor(analytics): move button and account events onto the shared builders Button taps track ButtonEvents.tapped with the shared Button enum, and the account actions and paidForAccount track AccountEvents. Every event name is unchanged; Create Account Payment's Fiat goes out as a number. Actions.kt is gone, along with Button.AllowCamera and Button.Sell, which had no callers. Compose callers read the service through rememberAnalytics(), since LocalAnalytics is typed as the base service without track(). The login tests assert the tracked button events with RecordingAnalytics instead of verifying action() on a mock. --- .../phone/PhoneVerificationViewModel.kt | 8 +- .../app/login/OnboardingFlowScreen.kt | 23 +++--- .../login/internal/LoginAccessKeyViewModel.kt | 9 ++- .../app/login/router/LoginViewModel.kt | 5 +- .../internal/LoginAccessKeyViewModelTest.kt | 29 ++++++- .../router/LoginViewModelCreateAccountTest.kt | 12 ++- .../login/router/LoginViewModelErrorTest.kt | 8 +- .../flipcash/app/tokens/TokenInfoScreen.kt | 5 +- .../com/flipcash/app/analytics/Actions.kt | 78 ------------------- .../com/flipcash/app/analytics/Analytics.kt | 12 +-- .../com/flipcash/app/analytics/Events.kt | 16 ---- .../internal/MixpanelAnalyticsDelegate.kt | 6 -- .../app/contacts/ContactCoordinator.kt | 4 +- .../internal/GooglePlayBillingClient.kt | 12 ++- .../NotificationPermissionScreen.kt | 15 ++-- .../NotificationRationalePermissionContent.kt | 5 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 9 ++- 17 files changed, 97 insertions(+), 159 deletions(-) delete mode 100644 apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Actions.kt 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..5da5f9aa2f 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,7 +3,7 @@ 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.analytics.events.AccountEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.core.extensions.onResult import com.flipcash.app.featureflags.FeatureFlagController @@ -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) 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..97278ec17a 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,7 +1,8 @@ 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.libs.coroutines.DispatcherProvider import com.flipcash.app.auth.AuthManager @@ -36,7 +37,7 @@ internal class LoginAccessKeyViewModel @Inject constructor( ): 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..51e0fc6c2b 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,7 +1,8 @@ package com.flipcash.app.login.router import androidx.lifecycle.viewModelScope -import com.flipcash.app.analytics.Button +import com.flipcash.analytics.Button +import com.flipcash.analytics.events.ButtonEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.auth.AuthManager import com.flipcash.features.login.R @@ -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() 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/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 8e12681dab..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,9 +31,10 @@ 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.analytics.Button import com.flipcash.analytics.TokenInfoSource +import com.flipcash.analytics.events.ButtonEvents import com.flipcash.analytics.events.TokenInfoEvents -import com.flipcash.app.analytics.Button import com.flipcash.app.analytics.analytics import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute @@ -116,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) } } 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 2ade9429ce..9d2ae86f81 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,20 +1,12 @@ package com.flipcash.app.analytics import androidx.compose.runtime.Composable -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 -interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics { - fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) - - fun buttonTapped(button: Button) { - action(button) - } -} +interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun track(event: com.flipcash.analytics.AnalyticsEvent) = Unit @@ -23,8 +15,6 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun onAppStarted() = Unit override fun unintentionalLogout() = Unit override fun action(action: AppAction, source: AppActionSource?) = Unit - - override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) = Unit } @Composable 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 index 79e6bae56c..9d3c033788 100644 --- 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 @@ -1,31 +1,15 @@ package com.flipcash.app.analytics -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.base58 import com.getcode.utils.base58 -import com.getcode.utils.getPublicKeyBase58 internal sealed interface AnalyticsEvent { val name: String fun toProperties(): Map = emptyMap() - 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() - ) - } - sealed interface PoolEvent : AnalyticsEvent { val id: ID override fun toProperties() = mapOf("ID" to id.base58) 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 1389fa272c..916852c39a 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 @@ -6,10 +6,8 @@ import com.flipcash.app.analytics.AnalyticsEvent import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.TokenSymbolResolver import com.flipcash.app.analytics.asProperties -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.services.flipcash.BuildConfig import com.getcode.utils.TraceType import com.getcode.utils.trace @@ -75,10 +73,6 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(name = action.value) } - override fun paidForAccount(price: Double, currency: CurrencyCode, owner: KeyPair) { - track(AnalyticsEvent.PaidForAccount(price, currency, owner)) - } - // region Internal private fun track(event: AnalyticsEvent, vararg extra: Pair) { 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..adba160a4b 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,7 +16,7 @@ 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.analytics.events.AccountEvents import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.contacts.device.DeviceContactLookup import com.flipcash.app.contacts.device.PickedContactData @@ -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/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..782f61fca5 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.analytics.events.AccountEvents import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.getcode.utils.getPublicKeyBase58 import com.flipcash.app.billing.BillingClient import com.flipcash.app.billing.BillingClientConnection import com.flipcash.app.billing.BillingClientState @@ -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 { 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/notifications/NotificationRationalePermissionContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt index 3ad3708a81..41e7000d89 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,7 +17,8 @@ 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.analytics.Button +import com.flipcash.analytics.events.ButtonEvents import com.flipcash.app.analytics.StubFlipcashAnalytics import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.android.extensions.launchAppSettings @@ -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) { 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 8bd09dcfec..7b02348746 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 @@ -6,10 +6,11 @@ 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.Button +import com.flipcash.analytics.Button import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.analytics import com.flipcash.app.core.AppRoute @@ -1595,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 @@ -1659,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) } } From d5027e5f60675121af91cba991cbb0e818a271af Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 14:33:07 -0400 Subject: [PATCH 15/20] refactor(analytics): delete the legacy analytics service Every caller now goes through FlipcashAnalytics.track/increment, so FlipcashAnalyticsService, StubFlipcashAnalytics, Events.kt and :libs:analytics have no users left. LocalAnalytics moves into the Flipcash analytics module, typed as FlipcashAnalytics, with previews providing FlipcashAnalytics.None. The delegate's app-start trace went with the lifecycle hooks it lived in; nothing called those hooks. --- .../kotlin/com/flipcash/app/MainActivity.kt | 8 +- .../app/balance/internal/WalletViewModel.kt | 6 +- .../internal/WalletMilestoneGatingTest.kt | 4 +- .../app/cash/internal/CashScreenViewModel.kt | 6 +- .../phone/PhoneVerificationViewModel.kt | 6 +- .../app/deposit/internal/DepositViewModel.kt | 6 +- .../login/internal/LoginAccessKeyViewModel.kt | 6 +- .../app/login/router/LoginViewModel.kt | 6 +- .../app/menu/internal/MenuScreenViewModel.kt | 6 +- .../app/messenger/internal/ChatViewModel.kt | 4 +- .../internal/name/NameEntryViewModel.kt | 6 +- .../app/withdrawal/WithdrawalViewModel.kt | 6 +- .../WithdrawalViewModelErrorTest.kt | 4 +- .../shared/analytics/build.gradle.kts | 3 - .../com/flipcash/app/analytics/Analytics.kt | 22 +---- .../com/flipcash/app/analytics/Events.kt | 54 ----------- .../app/analytics/RecordingAnalytics.kt | 2 +- .../app/analytics/inject/AnalyticsModule.kt | 8 +- .../internal/MixpanelAnalyticsDelegate.kt | 91 ++----------------- .../app/analytics/TokenSymbolPropertyTest.kt | 59 ------------ .../internal/delegates/EventStreamDelegate.kt | 4 +- .../internal/delegates/MessagingDelegate.kt | 4 +- .../app/contacts/ContactCoordinator.kt | 4 +- .../InternalPurchaseMethodController.kt | 6 +- .../app/billing/inject/BillingModule.kt | 6 +- .../internal/GooglePlayBillingClient.kt | 6 +- .../ContactPermissionScreenContent.kt | 10 +- .../NotificationPermissionScreenContent.kt | 8 +- .../NotificationRationalePermissionContent.kt | 8 +- .../delegates/BillPresentationDelegate.kt | 4 +- .../internal/delegates/CashLinkDelegate.kt | 4 +- .../internal/delegates/CodeScanDelegate.kt | 4 +- .../internal/delegates/DepositDelegate.kt | 4 +- .../delegates/GiftCardSharingDelegate.kt | 4 +- .../internal/delegates/TipCardDelegate.kt | 4 +- .../SessionControllerGiftCardErrorTest.kt | 4 +- .../delegates/DelegateEventEmissionTest.kt | 4 +- .../shared/tipping/TippingCoordinator.kt | 4 +- .../shared/tipping/TippingCoordinatorTest.kt | 4 +- .../flipcash/app/tokens/ui/SwapViewModel.kt | 6 +- libs/analytics/.gitignore | 2 - libs/analytics/build.gradle.kts | 13 --- .../libs/analytics/AnalyticsService.kt | 28 ------ settings.gradle.kts | 1 - 44 files changed, 102 insertions(+), 357 deletions(-) delete mode 100644 apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt delete mode 100644 apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/TokenSymbolPropertyTest.kt delete mode 100644 libs/analytics/.gitignore delete mode 100644 libs/analytics/build.gradle.kts delete mode 100644 libs/analytics/src/main/kotlin/com/getcode/libs/analytics/AnalyticsService.kt 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/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 47f498a313..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 @@ -3,7 +3,7 @@ package com.flipcash.app.balance.internal import androidx.lifecycle.viewModelScope import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.events.AddMoneyEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -36,7 +36,7 @@ internal class WalletViewModel @Inject constructor( userFlags: UserFlagsCoordinator, dispatchers: DispatcherProvider, purchaseMethodController: PurchaseMethodController, - analytics: FlipcashAnalyticsService, + analytics: FlipcashAnalytics, chatCoordinator: ChatCoordinator, feedCoordinator: ActivityFeedCoordinator, tokenCoordinator: TokenCoordinator, @@ -244,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/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/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 cd4a18beaf..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 @@ -3,7 +3,7 @@ package com.flipcash.app.cash.internal import androidx.lifecycle.viewModelScope import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.events.AddMoneyEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -59,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(), @@ -341,4 +341,4 @@ internal class CashScreenViewModel @Inject constructor( } } } -} \ No newline at end of file +} 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 5da5f9aa2f..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 @@ -4,7 +4,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.runtime.snapshotFlow import androidx.lifecycle.viewModelScope import com.flipcash.analytics.events.AccountEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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, @@ -432,4 +432,4 @@ internal class PhoneVerificationViewModel @Inject constructor( } } } -} \ No newline at end of file +} 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 729db50a35..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 @@ -3,7 +3,7 @@ package com.flipcash.app.deposit.internal import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.analytics.events.AddMoneyEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -36,7 +36,7 @@ internal class DepositViewModel @Inject constructor( resources: ResourceHelper, dispatchers: DispatcherProvider, featureFlags: FeatureFlagController, - analytics: FlipcashAnalyticsService, + analytics: FlipcashAnalytics, ) : BaseViewModel( initialState = State(), updateStateForEvent = updateStateForEvent, @@ -137,4 +137,4 @@ internal class DepositViewModel @Inject constructor( } } } -} \ No newline at end of file +} 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 97278ec17a..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 @@ -3,7 +3,7 @@ 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.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 @@ -33,7 +33,7 @@ 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 { @@ -90,4 +90,4 @@ internal class LoginAccessKeyViewModel @Inject constructor( 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 51e0fc6c2b..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 @@ -3,7 +3,7 @@ package com.flipcash.app.login.router import androidx.lifecycle.viewModelScope import com.flipcash.analytics.Button import com.flipcash.analytics.events.ButtonEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.auth.AuthManager import com.flipcash.features.login.R import com.flipcash.services.controllers.AccountController @@ -35,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(), @@ -241,4 +241,4 @@ class LoginViewModel @Inject constructor( } } } -} \ No newline at end of file +} 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 6102b23efb..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 @@ -4,7 +4,7 @@ import android.content.ClipboardManager import androidx.lifecycle.viewModelScope import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.events.AddMoneyEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -80,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, @@ -597,4 +597,4 @@ internal class MenuScreenViewModel @Inject constructor( } } } -} \ No newline at end of file +} 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 f937c6bd00..b55ebee5f6 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 @@ -18,7 +18,7 @@ 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.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.analytics.analytics import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.AppRoute @@ -163,7 +163,7 @@ 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, 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 75d646106c..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 @@ -4,7 +4,7 @@ import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.viewModelScope import com.flipcash.analytics.events.DisplayNameEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -38,7 +38,7 @@ 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, @@ -248,4 +248,4 @@ class NameEntryViewModel @Inject constructor( } } } -} \ No newline at end of file +} 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 9f261eb6f1..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 @@ -8,7 +8,7 @@ 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.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 @@ -75,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( @@ -673,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 0437b7414d..9f53b45dfd 100644 --- a/apps/flipcash/shared/analytics/build.gradle.kts +++ b/apps/flipcash/shared/analytics/build.gradle.kts @@ -15,9 +15,6 @@ dependencies { 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/Analytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Analytics.kt index 9d2ae86f81..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,23 +1,11 @@ package com.flipcash.app.analytics import androidx.compose.runtime.Composable -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 androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.staticCompositionLocalOf -interface FlipcashAnalyticsService : AnalyticsService, FlipcashAnalytics - -class StubFlipcashAnalytics : FlipcashAnalyticsService { - override fun track(event: com.flipcash.analytics.AnalyticsEvent) = Unit - override fun increment(counter: com.flipcash.analytics.PeopleCounter, amount: Double) = Unit - override fun onAppStart() = Unit - override fun onAppStarted() = Unit - override fun unintentionalLogout() = Unit - override fun action(action: AppAction, source: AppActionSource?) = 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/Events.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt deleted file mode 100644 index 9d3c033788..0000000000 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Events.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.flipcash.app.analytics - -import com.getcode.opencode.model.core.ID -import com.getcode.opencode.model.financial.Fiat -import com.getcode.opencode.model.financial.LocalFiat -import com.getcode.solana.keys.base58 -import com.getcode.utils.base58 - -internal sealed interface AnalyticsEvent { - val name: String - fun toProperties(): Map = emptyMap() - - 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" - } - -} - -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()) - } -} diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt index 9d211d1767..3a11fa9139 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt @@ -4,7 +4,7 @@ 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 : FlipcashAnalyticsService by StubFlipcashAnalytics() { +class RecordingAnalytics : FlipcashAnalytics { val events = mutableListOf() val increments = mutableListOf>() override fun track(event: AnalyticsEvent) { events += event } 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 e7a1cdaa75..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,7 +1,6 @@ package com.flipcash.app.analytics.inject import com.flipcash.app.analytics.FlipcashAnalytics -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.TokenSymbolResolver import com.flipcash.app.analytics.internal.MixpanelAnalyticsDelegate import com.mixpanel.android.mpmetrics.MixpanelAPI @@ -14,11 +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) - - @Provides - fun providesFlipcashAnalytics(service: FlipcashAnalyticsService): FlipcashAnalytics = service + ): 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 916852c39a..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,19 +1,13 @@ package com.flipcash.app.analytics.internal +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.AnalyticsEvent -import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.analytics.TokenSymbolResolver -import com.flipcash.app.analytics.asProperties -import com.getcode.libs.analytics.AppAction -import com.getcode.libs.analytics.AppActionSource import com.getcode.services.flipcash.BuildConfig 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 @@ -21,33 +15,9 @@ 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 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 track(event: com.flipcash.analytics.AnalyticsEvent) { + override fun track(event: AnalyticsEvent) { val properties = event.toMixpanelProperties(tokenSymbolResolver) if (BuildConfig.DEBUG) { @@ -69,17 +39,8 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( increment(counter.key, amount) } - override fun action(action: AppAction, source: AppActionSource?) { - track(name = action.value) - } - // 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) @@ -88,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 } @@ -121,9 +60,9 @@ private val MINT_PROPERTIES = mapOf( /** * Converts [this] event's properties to the values Mixpanel's JSON takes, with a ticker - * added beside every mint the [resolver] knows (see [withTokenSymbols]). + * added beside every mint the [resolver] knows. */ -internal fun com.flipcash.analytics.AnalyticsEvent.toMixpanelProperties( +internal fun AnalyticsEvent.toMixpanelProperties( resolver: TokenSymbolResolver, ): Map = buildMap { properties.forEach { (key, value) -> @@ -142,21 +81,3 @@ internal fun com.flipcash.analytics.AnalyticsEvent.toMixpanelProperties( resolver.symbolFor(mint)?.let { put(symbolKey, it) } } } - -/** - * 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. - */ -internal fun List>.withTokenSymbols( - 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 } - } - return this + symbols -} 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/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 a6f91ac9ed..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.analytics.PeopleCounter -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 { 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 269c28338c..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 @@ -9,7 +9,7 @@ import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map import com.flipcash.analytics.events.ChatEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -80,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 { 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 adba160a4b..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 @@ -17,7 +17,7 @@ import android.os.Handler import android.os.Looper import androidx.lifecycle.ProcessLifecycleOwner import com.flipcash.analytics.events.AccountEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 { 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 df84d1f0e9..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 @@ -2,7 +2,7 @@ package com.flipcash.app.funding.internal import com.flipcash.analytics.AddMoneyMethod import com.flipcash.analytics.events.AddMoneyEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -54,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()) @@ -195,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 782f61fca5..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 @@ -22,7 +22,7 @@ import com.android.billingclient.api.QueryPurchasesParams import com.android.billingclient.api.acknowledgePurchase import com.android.billingclient.api.consumePurchase import com.flipcash.analytics.events.AccountEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.getcode.utils.getPublicKeyBase58 import com.flipcash.app.billing.BillingClient import com.flipcash.app.billing.BillingClientConnection @@ -62,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 { @@ -516,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/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 41e7000d89..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 @@ -19,13 +19,13 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.flipcash.analytics.Button import com.flipcash.analytics.events.ButtonEvents -import com.flipcash.app.analytics.StubFlipcashAnalytics +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 @@ -133,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/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 736ee6ee0e..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 @@ -2,7 +2,7 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.analytics.State import com.flipcash.analytics.events.TransferEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -61,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, 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 33486ae31f..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 @@ -3,7 +3,7 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.analytics.State import com.flipcash.analytics.events.DeeplinkEvents import com.flipcash.analytics.events.TransferEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -52,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 { 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 207d56e682..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 @@ -3,7 +3,7 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.analytics.State import com.flipcash.analytics.events.ScanEvents import com.flipcash.analytics.events.TransferEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -57,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, 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 86d884cd2e..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 @@ -2,7 +2,7 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.analytics.AddMoneySource import com.flipcash.analytics.events.AddMoneyEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.session.DepositOperations @@ -36,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 { 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 e9c5448ab0..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 @@ -3,7 +3,7 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.analytics.CashLinkChoice import com.flipcash.analytics.State import com.flipcash.analytics.events.TransferEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -75,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, 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 431a6952ba..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,7 +1,7 @@ package com.flipcash.app.session.internal.delegates import com.flipcash.analytics.events.ScanEvents -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -49,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 { 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/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/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 3b682955a3..6a03043f47 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,6 +1,6 @@ package com.flipcash.shared.tipping -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.tipping.OwnTipCard @@ -74,7 +74,7 @@ class TippingCoordinator @Inject constructor( private val verifiedFiatCalculator: VerifiedFiatCalculator, private val resources: ResourceHelper, private val purchaseMethodController: PurchaseMethodController, - private val analytics: FlipcashAnalyticsService, + private val analytics: FlipcashAnalytics, 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..9ca49bb375 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,6 @@ package com.flipcash.shared.tipping -import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.funding.PurchaseMethodController import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ProfileController @@ -29,7 +29,7 @@ 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 analytics = mockk(relaxed = true) private val vibrator = mockk(relaxed = true) private fun buildCoordinator() = TippingCoordinator( 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 7b02348746..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 @@ -11,7 +11,7 @@ import com.flipcash.analytics.events.SwapEvents import com.flipcash.analytics.events.WalletEvents import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator import com.flipcash.analytics.Button -import com.flipcash.app.analytics.FlipcashAnalyticsService +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 @@ -122,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, @@ -2122,4 +2122,4 @@ class SwapViewModel @Inject constructor( } } } -} \ No newline at end of file +} 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", From 5b827db0ecb059f412784697609b3e16934d3749 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 15:47:22 -0400 Subject: [PATCH 16/20] test(chat): pin State: Failure on a failed chat send code/cash left successful at its default on this path, so a failed Sent Cash / Sent Tip reported State: Success; this branch sends Failure and the test keeps it that way. --- .../internal/ChatSendFailureAnalyticsTest.kt | 201 ++++++++++++++++++ 1 file changed, 201 insertions(+) create mode 100644 apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt 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..addd928129 --- /dev/null +++ b/apps/flipcash/features/messenger/src/test/kotlin/com/flipcash/app/messenger/internal/ChatSendFailureAnalyticsTest.kt @@ -0,0 +1,201 @@ +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.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.Dispatchers +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 kotlinx.coroutines.withContext +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() + } + + /** + * [BaseViewModel.dispatchEvent] hands the event to its `eventFlow` on a `viewModelScope.launch` + * fixed to the real `Dispatchers.Default` — [ChatViewModel] takes no `DispatcherProvider` to + * swap it for a test one, so the send handler this pins runs on a genuine background thread + * outside [advanceUntilIdle]'s virtual clock. `withContext(Dispatchers.IO)` hops onto another + * real dispatcher so this coroutine actually blocks in wall-clock time, giving that thread a + * window to run rather than racing it. + */ + private suspend fun awaitAnalyticsEvent(name: String, timeoutMs: Long = 5_000) { + val deadline = System.nanoTime() + timeoutMs * 1_000_000 + while (analytics.events.none { it.name == name }) { + check(System.nanoTime() < deadline) { "Timed out waiting for a \"$name\" analytics event" } + withContext(Dispatchers.IO) { Thread.sleep(5) } + } + } + + 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, + ) + + @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() + awaitAnalyticsEvent("Sent Cash") + + 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() + awaitAnalyticsEvent("Sent Tip") + + val event = analytics.events.single { it.name == "Sent Tip" } + assertEquals(PropertyValue.Text(AnalyticsState.FAILURE.value), event.properties["State"]) + assertTrue(event.properties.containsKey("Error")) + } +} From efc71a2fc3673372f86b4d60dc1e051503248924 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 15:51:49 -0400 Subject: [PATCH 17/20] refactor(analytics): move RecordingAnalytics into test fixtures It sat in the main source set, so it shipped in the app and production code could inject it. It now lives in the analytics module's testFixtures, which the nine modules whose tests use it pull in with testImplementation(testFixtures(...)), the same way :libs:coroutines and :ui:resources share their fakes. The fixture compilation needs the Compose BOM and ui on its classpath because the module applies the Compose compiler. --- apps/flipcash/features/balance/build.gradle.kts | 1 + apps/flipcash/features/cash/build.gradle.kts | 1 + apps/flipcash/features/login/build.gradle.kts | 1 + apps/flipcash/features/messenger/build.gradle.kts | 1 + apps/flipcash/features/tokens/build.gradle.kts | 1 + apps/flipcash/features/user-profile/build.gradle.kts | 1 + apps/flipcash/shared/analytics/build.gradle.kts | 6 ++++++ .../kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt | 0 apps/flipcash/shared/chat/build.gradle.kts | 1 + apps/flipcash/shared/session/build.gradle.kts | 1 + apps/flipcash/shared/tokens/build.gradle.kts | 1 + 11 files changed, 15 insertions(+) rename apps/flipcash/shared/analytics/src/{main => testFixtures}/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt (100%) 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/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/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/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index 459b903778..a18002f62e 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(project(":apps:flipcash:shared:analytics")) + testImplementation(testFixtures(project(":apps:flipcash:shared:analytics"))) implementation(project(":apps:flipcash:shared:blob")) implementation(project(":apps:flipcash:shared:blocklist")) implementation(project(":apps:flipcash:shared:chat")) 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/user-profile/build.gradle.kts b/apps/flipcash/features/user-profile/build.gradle.kts index f5f3419c9c..15c5ad2dec 100644 --- a/apps/flipcash/features/user-profile/build.gradle.kts +++ b/apps/flipcash/features/user-profile/build.gradle.kts @@ -16,6 +16,7 @@ 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")) diff --git a/apps/flipcash/shared/analytics/build.gradle.kts b/apps/flipcash/shared/analytics/build.gradle.kts index 9f53b45dfd..371f23ce36 100644 --- a/apps/flipcash/shared/analytics/build.gradle.kts +++ b/apps/flipcash/shared/analytics/build.gradle.kts @@ -4,9 +4,15 @@ 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) diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt b/apps/flipcash/shared/analytics/src/testFixtures/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt similarity index 100% rename from apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt rename to apps/flipcash/shared/analytics/src/testFixtures/kotlin/com/flipcash/app/analytics/RecordingAnalytics.kt 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/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/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")) From e5c12b424d370cf22e1b46eef2aca23c3ed25815 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 19:41:20 -0400 Subject: [PATCH 18/20] refactor(tipping): drop the unused analytics dependency from TippingCoordinator TippingCoordinator took a FlipcashAnalytics it never called, both before and after the track() migration. Remove the constructor parameter and the module's dependency on :apps:flipcash:shared:analytics. --- apps/flipcash/shared/tipping/build.gradle.kts | 1 - .../kotlin/com/flipcash/shared/tipping/TippingCoordinator.kt | 2 -- .../com/flipcash/shared/tipping/TippingCoordinatorTest.kt | 3 --- 3 files changed, 6 deletions(-) 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 6a03043f47..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,6 +1,5 @@ package com.flipcash.shared.tipping -import com.flipcash.app.analytics.FlipcashAnalytics import com.flipcash.app.core.bill.Scannable import com.flipcash.app.core.chat.ChatIdentifier import com.flipcash.app.core.tipping.OwnTipCard @@ -74,7 +73,6 @@ class TippingCoordinator @Inject constructor( private val verifiedFiatCalculator: VerifiedFiatCalculator, private val resources: ResourceHelper, private val purchaseMethodController: PurchaseMethodController, - private val analytics: FlipcashAnalytics, 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 9ca49bb375..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.FlipcashAnalytics 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, ) From 5b53fb01cfe26aaef05019d4d396aa09690d45da Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 19:44:05 -0400 Subject: [PATCH 19/20] test(messenger): run the chat send-failure test on the test dispatcher ChatViewModel dispatched events on the real Dispatchers.Default, so ChatSendFailureAnalyticsTest polled for up to 5s and could leave the send handler running after the test returned. CI then failed ReportViewModelTest, the next messenger test, with UncaughtExceptionsBeforeTest, which is how kotlinx-coroutines-test reports an exception thrown by an earlier test's leftover coroutine. It did not reproduce locally. Inject DispatcherProvider into ChatViewModel, as NameEntryViewModel does, and pass the test's dispatcher so no work outlives the test. Production still gets Dispatchers.Default. --- .../features/messenger/build.gradle.kts | 1 + .../app/messenger/internal/ChatViewModel.kt | 3 +++ .../internal/ChatSendFailureAnalyticsTest.kt | 22 ++----------------- 3 files changed, 6 insertions(+), 20 deletions(-) diff --git a/apps/flipcash/features/messenger/build.gradle.kts b/apps/flipcash/features/messenger/build.gradle.kts index a18002f62e..4c59fcef43 100644 --- a/apps/flipcash/features/messenger/build.gradle.kts +++ b/apps/flipcash/features/messenger/build.gradle.kts @@ -9,6 +9,7 @@ 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 b55ebee5f6..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 @@ -32,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 @@ -170,9 +171,11 @@ internal class ChatViewModel @Inject constructor( 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 { 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 index addd928129..8c63e3cace 100644 --- 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 @@ -15,6 +15,7 @@ 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 @@ -39,14 +40,12 @@ import com.getcode.util.resources.ResourceHelper import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import kotlinx.coroutines.Dispatchers 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 kotlinx.coroutines.withContext import org.junit.After import org.junit.Before import org.junit.Rule @@ -115,22 +114,6 @@ class ChatSendFailureAnalyticsTest { BottomBarManager.clear() } - /** - * [BaseViewModel.dispatchEvent] hands the event to its `eventFlow` on a `viewModelScope.launch` - * fixed to the real `Dispatchers.Default` — [ChatViewModel] takes no `DispatcherProvider` to - * swap it for a test one, so the send handler this pins runs on a genuine background thread - * outside [advanceUntilIdle]'s virtual clock. `withContext(Dispatchers.IO)` hops onto another - * real dispatcher so this coroutine actually blocks in wall-clock time, giving that thread a - * window to run rather than racing it. - */ - private suspend fun awaitAnalyticsEvent(name: String, timeoutMs: Long = 5_000) { - val deadline = System.nanoTime() + timeoutMs * 1_000_000 - while (analytics.events.none { it.name == name }) { - check(System.nanoTime() < deadline) { "Timed out waiting for a \"$name\" analytics event" } - withContext(Dispatchers.IO) { Thread.sleep(5) } - } - } - private fun createViewModel(): ChatViewModel = ChatViewModel( chatCoordinator = chatCoordinator, contactCoordinator = contactCoordinator, @@ -150,6 +133,7 @@ class ChatSendFailureAnalyticsTest { linkCardResolver = linkCardResolver, cashLinkClaims = cashLinkClaims, chatDraftStore = chatDraftStore, + dispatchers = TestDispatcherProvider(mainCoroutineRule.dispatcher), ) @Test @@ -172,7 +156,6 @@ class ChatSendFailureAnalyticsTest { vm.dispatchEvent(ChatViewModel.Event.OnContactFound(contact)) vm.dispatchEvent(ChatViewModel.Event.OnSendRequested(amount, token)) advanceUntilIdle() - awaitAnalyticsEvent("Sent Cash") val event = analytics.events.single { it.name == "Sent Cash" } assertEquals(PropertyValue.Text(AnalyticsState.FAILURE.value), event.properties["State"]) @@ -192,7 +175,6 @@ class ChatSendFailureAnalyticsTest { vm.dispatchEvent(ChatViewModel.Event.OnTipUserResolved(userId, profile)) vm.dispatchEvent(ChatViewModel.Event.OnSendRequested(amount, token)) advanceUntilIdle() - awaitAnalyticsEvent("Sent Tip") val event = analytics.events.single { it.name == "Sent Tip" } assertEquals(PropertyValue.Text(AnalyticsState.FAILURE.value), event.properties["State"]) From d6a9c63600a3ea3c79bccd3588d03d810d687fe0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 24 Sep 2026 22:30:42 -0400 Subject: [PATCH 20/20] fix(tokens): track currency-info opens and shares from the card overlay Tapping a wallet card opens CurrencyInfoExpansion, an overlay rather than the pushed TokenInfoScreen, and the overlay tracked nothing. Wallet opens never sent `Token Info: Opened From Wallet`, and its share button never sent `Button: Share Token Info`. Deeplinks open the same overlay through beginExpanded, so `Opened From Deeplink` was missing too. iOS sends all three from WalletScreen.openCard, openCardImmediately and the share link. The open event fires once per open, keyed on the mint, with WALLET when the card flew out of the deck and DEEPLINK when there was no source card. The "already tracked" flag is rememberSaveable because a pushed Give, Convert or Withdraw takes the wallet entry, and the overlay with it, out of composition. The entry's saved state outlives that, so coming back does not count a second open. --- .../app/tokens/CurrencyInfoExpansion.kt | 33 +++++++++++++++++ .../CurrencyInfoExpansionAnalyticsTest.kt | 36 +++++++++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 apps/flipcash/features/tokens/src/test/kotlin/com/flipcash/app/tokens/CurrencyInfoExpansionAnalyticsTest.kt 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/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) + } +}