Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/flipcash/features/currency-creator/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ android {
}

dependencies {
implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:features:bill-customization"))
implementation(project(":apps:flipcash:features:tokens"))
implementation(project(":apps:flipcash:shared:bills"))
Expand All @@ -21,4 +22,9 @@ dependencies {

implementation(project(":services:flipcash"))
implementation(project(":services:opencode"))

testImplementation(kotlin("test"))
testImplementation(libs.bundles.unit.testing)
testImplementation(project(":apps:flipcash:shared:analytics:test-fixtures"))
testImplementation(project(":ui:resources:test-fixtures"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@ import android.net.Uri
import androidx.compose.foundation.text.input.TextFieldState
import androidx.core.text.trimmedLength
import androidx.lifecycle.viewModelScope
import com.flipcash.analytics.LaunchMethod
import com.flipcash.analytics.State as AnalyticsState
import com.flipcash.analytics.events.CurrencyLaunchEvents
import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.app.analytics.analytics
import com.flipcash.app.core.bill.Scannable
import com.flipcash.app.core.data.Loadable
import com.flipcash.app.core.extensions.flatMapResult
Expand Down Expand Up @@ -94,6 +99,7 @@ internal class CurrencyCreatorViewModel @Inject constructor(
val purchaseMethodController: PurchaseMethodController,
private val currencyCreatorCoordinator: CurrencyCreatorCoordinator,
private val exchange: Exchange,
private val analytics: FlipcashAnalytics,
) : BaseViewModel<CurrencyCreatorViewModel.State, CurrencyCreatorViewModel.Event>(
initialState = State(),
updateStateForEvent = updateStateForEvent,
Expand Down Expand Up @@ -616,6 +622,7 @@ internal class CurrencyCreatorViewModel @Inject constructor(

if (fast.isSuccess) {
val completed = finalizeLaunch(mint)
trackLaunch(mint, success = completed)
if (completed) {
delay(InlineCompletion.SUCCESS_HOLD)
dispatchEvent(Event.LaunchFinished)
Expand All @@ -628,6 +635,7 @@ internal class CurrencyCreatorViewModel @Inject constructor(
}

if (fast.exceptionOrNull() !is BalancePollError.Timeout) {
trackLaunch(mint, success = false, error = fast.exceptionOrNull())
dispatchEvent(Event.UpdateProcessingState())
showLaunchFailed()
return
Expand All @@ -642,11 +650,34 @@ internal class CurrencyCreatorViewModel @Inject constructor(
interval = InlineCompletion.SLOW_POLL_INTERVAL,
)
if (slow.isSuccess && finalizeLaunch(mint)) {
trackLaunch(mint, success = true)
return
}
trackLaunch(mint, success = false, error = slow.exceptionOrNull())
dispatchEvent(Event.UpdateProcessingState(error = true))
}

/**
* Reports how the launch ended, once it is settled one way or the other. Reserves is a launch
* funded with USDF, Currency one funded with another held currency. The cost is reported in
* the user's currency, as iOS does.
*/
private fun trackLaunch(launched: Mint, success: Boolean, error: Throwable? = null) {
val paymentMint = stateFlow.value.fundingMint ?: return
val cost = stateFlow.value.totalCost.convertingTo(exchange.preferredRate)
analytics.track(
CurrencyLaunchEvents.launched(
method = if (paymentMint == Mint.usdf) LaunchMethod.RESERVES else LaunchMethod.CURRENCY,
state = if (success) AnalyticsState.SUCCESS else AnalyticsState.FAILURE,
mint = launched.analytics,
paymentMint = paymentMint.analytics,
fiat = cost.decimalValue,
currency = cost.currencyCode.name,
error = error?.analytics,
)
)
}

/** Resolves the new token and its launch bill, then marks the launch successful. */
private suspend fun finalizeLaunch(mint: Mint): Boolean {
val metadata = tokenCoordinator.getTokenMetadata(mint).getOrNull() ?: return false
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
package com.flipcash.app.currencycreator.internal

import com.flipcash.analytics.LaunchMethod
import com.flipcash.analytics.State
import com.flipcash.analytics.events.CurrencyLaunchEvents
import com.flipcash.app.analytics.RecordingAnalytics
import com.flipcash.app.core.MainCoroutineRule
import com.flipcash.app.core.dispatchers.TestDispatchers
import com.flipcash.app.currencycreator.CurrencyCreatorCoordinator
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.tokens.BalancePoller
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.services.controllers.ModerationController
import com.flipcash.services.user.UserManager
import com.getcode.opencode.controllers.CurrencyController
import com.getcode.opencode.exchange.Exchange
import com.getcode.opencode.exchange.VerifiedFiatCalculator
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.solana.keys.base58
import com.getcode.util.resources.ContentReader
import com.getcode.util.resources.FakeResourceHelper
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals

@OptIn(ExperimentalCoroutinesApi::class)
class CurrencyCreatorLaunchAnalyticsTest {

@get:Rule
var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher())

private val userManager = mockk<UserManager>(relaxed = true)
private val tokenCoordinator = mockk<TokenCoordinator>(relaxed = true)
private val balancePoller = mockk<BalancePoller>(relaxed = true)
private val exchange = mockk<Exchange>(relaxed = true)
private val analytics = RecordingAnalytics()

private fun mint(seed: Int) = Mint(List(32) { seed.toByte() })

private fun TestScope.launchFailing(fundedWith: Mint, launched: Mint, cause: Throwable) {
every { exchange.preferredRate } returns Rate.oneToOne
every { tokenCoordinator.balanceForToken(any<Mint>()) } returns flowOf(Fiat.Zero)
coEvery { balancePoller.awaitBalanceChange(launched, any(), any(), any(), any()) } returns Result.failure(cause)
// No account cluster ends the LaunchToken pipeline early, which still records the funding mint.
every { userManager.accountCluster } returns null

val vm = CurrencyCreatorViewModel(
dispatchers = TestDispatchers(testScheduler),
userManager = userManager,
userFlags = mockk<UserFlagsCoordinator>(relaxed = true),
moderationController = mockk<ModerationController>(relaxed = true),
currencyController = mockk<CurrencyController>(relaxed = true),
verifiedFiatCalculator = mockk<VerifiedFiatCalculator>(relaxed = true),
tokenCoordinator = tokenCoordinator,
balancePoller = balancePoller,
resources = FakeResourceHelper(),
contentReader = mockk<ContentReader>(relaxed = true),
purchaseMethodController = mockk<PurchaseMethodController>(relaxed = true),
currencyCreatorCoordinator = mockk<CurrencyCreatorCoordinator>(relaxed = true),
exchange = exchange,
analytics = analytics,
)
vm.dispatchEvent(CurrencyCreatorViewModel.Event.LaunchToken(fundedWith))
vm.dispatchEvent(CurrencyCreatorViewModel.Event.PurchaseSubmitted(mockk(relaxed = true), launched))
advanceUntilIdle()
}

@Test
fun `a launch funded with USDF tracks Currency Launch With Reserves on failure`() =
runTest(mainCoroutineRule.dispatcher) {
val launched = mint(1)
launchFailing(Mint.usdf, launched, RuntimeException("boom"))

assertEquals(
listOf(
CurrencyLaunchEvents.launched(
LaunchMethod.RESERVES, State.FAILURE, launched.base58(), Mint.usdf.base58(),
5.0, CurrencyCode.USD.name, "boom",
)
),
analytics.events,
)
}

@Test
fun `a launch funded with another currency tracks Currency Launch With Currency on failure`() =
runTest(mainCoroutineRule.dispatcher) {
val launched = mint(1)
val funding = mint(2)
launchFailing(funding, launched, RuntimeException("boom"))

assertEquals(
listOf(
CurrencyLaunchEvents.launched(
LaunchMethod.CURRENCY, State.FAILURE, launched.base58(), funding.base58(),
5.0, CurrencyCode.USD.name, "boom",
)
),
analytics.events,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.app.login.router

import androidx.lifecycle.viewModelScope
import com.flipcash.analytics.Button
import com.flipcash.analytics.events.AccountEvents
import com.flipcash.analytics.events.ButtonEvents
import com.flipcash.app.analytics.FlipcashAnalytics
import com.flipcash.app.auth.AuthManager
Expand Down Expand Up @@ -92,7 +93,10 @@ class LoginViewModel @Inject constructor(
eventFlow
.filterIsInstance<Event.CreateAccount>()
.filter { createInFlight.compareAndSet(false, true) }
.onEach { analytics.track(ButtonEvents.tapped(Button.CREATE_ACCOUNT)) }
.onEach {
analytics.track(ButtonEvents.tapped(Button.CREATE_ACCOUNT))
analytics.track(AccountEvents.createAccount())
}
.onEach {
try {
authManager.createAccount()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.app.login.router

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.flipcash.analytics.Button
import com.flipcash.analytics.events.AccountEvents
import com.flipcash.analytics.events.ButtonEvents
import com.flipcash.app.analytics.RecordingAnalytics
import com.flipcash.app.auth.AuthManager
Expand Down Expand Up @@ -92,7 +93,10 @@ class LoginViewModelCreateAccountTest {
// 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())
assertEquals(
listOf(ButtonEvents.tapped(Button.CREATE_ACCOUNT), AccountEvents.createAccount()),
analytics.events,
)
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.flipcash.app.login.router

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.flipcash.analytics.Button
import com.flipcash.analytics.events.AccountEvents
import com.flipcash.analytics.events.ButtonEvents
import com.flipcash.app.analytics.RecordingAnalytics
import com.flipcash.app.auth.AuthManager
Expand Down Expand Up @@ -85,7 +86,10 @@ class LoginViewModelErrorTest {
advanceUntilIdle()

assertTrue(BottomBarManager.messages.value.any { it.title == "error_title_createAccountFailed" })
assertEquals(ButtonEvents.tapped(Button.CREATE_ACCOUNT), analytics.events.single())
assertEquals(
listOf(ButtonEvents.tapped(Button.CREATE_ACCOUNT), AccountEvents.createAccount()),
analytics.events,
)
}

@Test
Expand Down
2 changes: 1 addition & 1 deletion apps/flipcash/features/menu/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dependencies {
implementation(libs.bundles.haze)

implementation(project(":apps:flipcash:shared:analytics"))
testImplementation(testFixtures(project(":apps:flipcash:shared:analytics")))
testImplementation(project(":apps:flipcash:shared:analytics:test-fixtures"))
implementation(project(":apps:flipcash:shared:appupdates"))
implementation(project(":apps:flipcash:shared:authentication"))
implementation(project(":apps:flipcash:shared:bills"))
Expand Down
2 changes: 1 addition & 1 deletion apps/flipcash/features/myaccount/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ dependencies {
implementation(libs.compose.paging)

implementation(project(":apps:flipcash:shared:analytics"))
testImplementation(testFixtures(project(":apps:flipcash:shared:analytics")))
testImplementation(project(":apps:flipcash:shared:analytics:test-fixtures"))
implementation(project(":apps:flipcash:shared:appsettings"))
implementation(project(":apps:flipcash:shared:appupdates"))
implementation(project(":apps:flipcash:shared:authentication"))
Expand Down
2 changes: 2 additions & 0 deletions apps/flipcash/shared/authentication/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ dependencies {
implementation(libs.kotlinx.coroutines.play.services)
implementation(libs.androidx.datastore)

implementation(project(":apps:flipcash:shared:analytics"))
implementation(project(":apps:flipcash:shared:appsettings"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:contacts"))
Expand All @@ -28,4 +29,5 @@ dependencies {
testImplementation(libs.robolectric)
testImplementation(libs.grpc.stub)
testImplementation(project(":libs:coroutines:test-fixtures"))
testImplementation(project(":apps:flipcash:shared:analytics:test-fixtures"))
}
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import com.flipcash.analytics.events.AccountEvents
import com.flipcash.app.analytics.FlipcashAnalytics
import javax.inject.Inject
import javax.inject.Singleton

Expand Down Expand Up @@ -67,7 +69,7 @@ class AuthManager @Inject constructor(
private val wrongKeyChats: WrongKeyChats,
private val networkObserver: NetworkConnectivityListener,
private val dispatchers: DispatcherProvider,
// private val analytics: AnalyticsService,
private val analytics: FlipcashAnalytics,
) : CoroutineScope by CoroutineScope(dispatchers.IO) {

init {
Expand Down Expand Up @@ -129,7 +131,10 @@ class AuthManager @Inject constructor(
is LookupResult.ExistingAccountFound -> {
val token = result.entropy
softLogin(token)
.onSuccess { onInitialized() }
.onSuccess {
analytics.track(AccountEvents.autoLoginComplete())
onInitialized()
}
}

// No account on this device: resolve to a terminal LoggedOut state so the
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package com.flipcash.app.auth

import androidx.core.app.NotificationManagerCompat
import com.flipcash.analytics.events.AccountEvents
import com.flipcash.app.analytics.RecordingAnalytics
import com.flipcash.app.appsettings.AppSettingsCoordinator
import com.flipcash.app.auth.internal.accounts.AccountStore
import com.flipcash.app.auth.internal.credentials.AccountMetadata
Expand Down Expand Up @@ -80,6 +82,7 @@ class AuthManagerTest {

private val networkConnectivityListener = mockk<NetworkConnectivityListener>(relaxed = true)

private val analytics = RecordingAnalytics()
private lateinit var authManager: AuthManager

@Before
Expand Down Expand Up @@ -130,6 +133,7 @@ class AuthManagerTest {
wrongKeyChats = wrongKeyChats,
dispatchers = dispatchers,
networkObserver = networkConnectivityListener,
analytics = analytics,
)
}

Expand Down Expand Up @@ -566,6 +570,7 @@ class AuthManagerTest {

coVerify { credentialManager.lookup() }
verify { persistence.openDatabase(entropy) }
assertEquals(listOf(AccountEvents.autoLoginComplete()), analytics.events)
}

// The trusted-website list is device-level and must outlive Log Out and Switch Accounts, both of
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -213,6 +213,37 @@ class SwapViewModelErrorTest {
)
}

@Test
fun `a buy paid with a held currency tracks Token Purchase With Currency`() = runTest(mainCoroutineRule.dispatcher) {
dispatchers = TestDispatchers(testScheduler)
transactionController.stub {
onBlocking { swap(any(), any(), anyOrNull(), any(), any(), anyOrNull(), any(), anyOrNull()) } doReturn
Result.failure(RuntimeException("swap failed"))
}

val target = mockk<Token>(relaxed = true) { every { address } returns Mint.usdf }
val targetWithBalance = mockk<TokenWithBalance>(relaxed = true) {
every { this@mockk.token } returns target
}
val fundingMint = Mint(ByteArray(32) { 3 }.toList())
val funding = mockk<Token>(relaxed = true) { every { address } returns fundingMint }
val fundingWithBalance = mockk<TokenWithBalance>(relaxed = true) {
every { this@mockk.token } returns funding
}
val amount = VerifiedFiat(mockk<LocalFiat>(relaxed = true), null)

val vm = createViewModel()
vm.dispatchEvent(SwapViewModel.Event.OnPurposeChanged(SwapPurpose.Buy(mockk(relaxed = true))))
vm.dispatchEvent(SwapViewModel.Event.OnSelectedTokenChanged(targetWithBalance))
vm.dispatchEvent(SwapViewModel.Event.OnFundingTokenResolved(fundingWithBalance))
vm.dispatchEvent(SwapViewModel.Event.ProceedWithPurchase(amount))
advanceUntilIdle()

val result = analytics.events.single { it.name == "Token Purchase With Currency" }
assertEquals(PropertyValue.Text("swap failed"), result.properties["Error"])
assertTrue(analytics.events.none { it.name == "Token Purchase With Reserves" })
}

@Test
fun `sell failure shows buySellFailed error`() = runTest(mainCoroutineRule.dispatcher) {
dispatchers = TestDispatchers(testScheduler)
Expand Down
Loading
Loading