diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/analytics/TokenCoordinatorSymbolResolver.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/analytics/TokenCoordinatorSymbolResolver.kt new file mode 100644 index 0000000000..f1079cd3d1 --- /dev/null +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/analytics/TokenCoordinatorSymbolResolver.kt @@ -0,0 +1,34 @@ +package com.flipcash.app.internal.analytics + +import com.flipcash.app.analytics.TokenSymbolResolver +import com.flipcash.app.tokens.TokenCoordinator +import com.getcode.solana.keys.Mint +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +/** + * Binds analytics' mint→ticker lookup to the token cache. + * + * Lives in `:app` because it is the only module that depends on both + * `:shared:analytics` and `:shared:tokens`. + */ +@Module +@InstallIn(SingletonComponent::class) +object TokenSymbolResolverModule { + + @Provides + @Singleton + fun providesTokenSymbolResolver( + tokenCoordinator: TokenCoordinator, + ): TokenSymbolResolver = TokenSymbolResolver { mintBase58 -> + // Cache-only and synchronous: analytics must never block or fetch. + // An uncached mint yields null, and the property is omitted. + runCatching { Mint(mintBase58) }.getOrNull() + ?.let { tokenCoordinator.cachedToken(it) } + ?.symbol + ?.takeIf { it.isNotBlank() } + } +} diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt index c1894a8527..df719ecd6f 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/MainRoot.kt @@ -24,6 +24,7 @@ import androidx.navigation3.runtime.NavKey import com.flipcash.app.android.R import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.navigation.DeeplinkAction import com.flipcash.app.core.navigation.homeRoute import com.flipcash.app.core.extensions.navigateAll @@ -199,6 +200,7 @@ internal fun buildNavGraphForLaunch( listOf( AppRoute.UpdateUserProfile( origin = AppRoute.OnboardingFlow(), + nameSource = DisplayNameSource.Onboarding, includeName = true, includePhoto = false, target = AppRoute.OnboardingFlow( diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 729de1e98a..11f78dda53 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -146,6 +146,7 @@ sealed interface AppRoute : NavKey, Parcelable { @Parcelize data class UpdateUserProfile( val origin: AppRoute, + val nameSource: DisplayNameSource, val includeName: Boolean = true, val includePhoto: Boolean = true, val target: AppRoute? = null, @@ -368,3 +369,7 @@ private fun buildUpdateUserProfileStack( if (includeName) add(UpdateProfileStep.Name) if (includePhoto) add(UpdateProfileStep.Photo) } + +/** Where a display-name entry flow was launched from. Reported as the `Source` analytics property. */ +@Serializable +enum class DisplayNameSource { Onboarding, MyAccount, TipCardSetup } 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 343aaaadc4..e634e387af 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 @@ -25,6 +25,7 @@ import androidx.navigation3.runtime.entryProvider import com.flipcash.app.analytics.Action import com.flipcash.app.analytics.Button import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.extensions.openAsSheet import com.flipcash.app.core.navigation.homeRoute @@ -269,6 +270,7 @@ private fun FlowNavigator.proceedToNameOrPermi navigate( AppRoute.UpdateUserProfile( origin = AppRoute.OnboardingFlow(), + nameSource = DisplayNameSource.Onboarding, includeName = true, includePhoto = false, target = AppRoute.OnboardingFlow( 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 f59ef68d45..285de0dfed 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 @@ -710,7 +710,7 @@ internal class ChatViewModel @Inject constructor( // A payment into a tip DM is a tip; a contact DM is a plain cash send. val transferEvent = if (stateFlow.value.participant is ChatParticipant.TipUser) { - Analytics.Transfer.SentTip + Analytics.Transfer.SentTip(TipOrigin.CHAT) } else { Analytics.Transfer.SentCash } diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt index 89d9a683a5..a679c28bc7 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/MyAccountScreen.kt @@ -10,6 +10,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreen import com.flipcash.app.myaccount.internal.myaccount.MyAccountScreenViewModel import com.flipcash.core.R @@ -49,6 +50,7 @@ fun MyAccountScreen() { navigator.push( AppRoute.UpdateUserProfile( origin = AppRoute.Menu.MyAccount, + nameSource = DisplayNameSource.MyAccount, includeName = true, includePhoto = false, ) diff --git a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt index 8a02355d0b..9315966d27 100644 --- a/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt +++ b/apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/UserProfileScreen.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.core.AppRoute +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.myaccount.internal.userprofile.UserProfileScreenContent import com.flipcash.app.myaccount.internal.userprofile.UserProfileViewModel import com.flipcash.core.R @@ -50,6 +51,7 @@ fun UserProfileScreen() { navigator.push( AppRoute.UpdateUserProfile( origin = AppRoute.Menu.UserProfile, + nameSource = DisplayNameSource.MyAccount, includeName = true, includePhoto = false, ) @@ -64,6 +66,7 @@ fun UserProfileScreen() { navigator.push( AppRoute.UpdateUserProfile( origin = AppRoute.Menu.UserProfile, + nameSource = DisplayNameSource.MyAccount, includeName = false, includePhoto = true, ) diff --git a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt index 6f8f102e1a..8fc168c3c7 100644 --- a/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt +++ b/apps/flipcash/features/tipping/src/main/kotlin/com/flipcash/app/tipping/internal/screens/TipInfoScreen.kt @@ -18,6 +18,7 @@ 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.core.AppRoute +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.tipping.TipResult import com.flipcash.app.core.tipping.TipStep @@ -55,6 +56,7 @@ internal fun TipInfoScreen() { flowNavigator.navigate( AppRoute.UpdateUserProfile( origin = AppRoute.Sheets.Tips(), + nameSource = DisplayNameSource.TipCardSetup, includeName = userManager?.profile?.displayName.isNullOrEmpty(), includePhoto = false, // explicity false for now target = AppRoute.Sheets.Tips(resumed = true), diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt index 5659551483..b09d749964 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/UserProfileSetupFlowScreen.kt @@ -58,7 +58,7 @@ private fun profileUpdateProvider( route: AppRoute.UpdateUserProfile, ): (NavKey) -> NavEntry = entryProvider { annotatedEntry { - NameEntryScreen(allowBack = route.allowBack) + NameEntryScreen(source = route.nameSource, allowBack = route.allowBack) } annotatedEntry { PhotoSelectionScreen() diff --git a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt index fd21dc810c..8e3abd7cfd 100644 --- a/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt +++ b/apps/flipcash/features/user-profile/src/main/kotlin/com/flipcash/app/userprofile/internal/name/NameEntryScreen.kt @@ -25,6 +25,7 @@ import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.unit.dp import androidx.hilt.lifecycle.viewmodel.compose.hiltViewModel import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.ui.DisplayTextInput import com.flipcash.app.core.ui.transitions.SharedTransition import com.flipcash.app.core.ui.transitions.sharedElementTransition @@ -43,6 +44,7 @@ import kotlinx.coroutines.flow.onEach @Composable internal fun NameEntryScreen( + source: DisplayNameSource, allowBack: Boolean = true, ) { val flowNavigator = rememberFlowNavigator() @@ -67,7 +69,7 @@ internal fun NameEntryScreen( } else { Spacer(Modifier.statusBarsPadding().padding(2.5.dp)) } - NameEntryScreenContent(state, viewModel::dispatchEvent) + NameEntryScreenContent(state, source, viewModel::dispatchEvent) } LaunchedEffect(viewModel) { @@ -81,6 +83,7 @@ internal fun NameEntryScreen( @Composable private fun NameEntryScreenContent( state: NameEntryViewModel.State, + source: DisplayNameSource, dispatchEvent: (NameEntryViewModel.Event) -> Unit, ) { val keyboard = rememberKeyboardController() @@ -115,7 +118,7 @@ private fun NameEntryScreenContent( isSuccess = state.processingState.success, onClick = { keyboard.hideIfVisible { - dispatchEvent(NameEntryViewModel.Event.CheckName) + dispatchEvent(NameEntryViewModel.Event.CheckName(source)) } }, ) @@ -143,7 +146,7 @@ private fun NameEntryScreenContent( ), onKeyboardAction = { keyboard.hideIfVisible { - dispatchEvent(NameEntryViewModel.Event.CheckName) + dispatchEvent(NameEntryViewModel.Event.CheckName(source)) } }, ) 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 58514d15ad..1b9844882c 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,6 +3,8 @@ package com.flipcash.app.userprofile.internal.name import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.foundation.text.input.setTextAndPlaceCursorAtEnd import androidx.lifecycle.viewModelScope +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.extensions.flatMapResult import com.flipcash.app.core.extensions.onResult import com.flipcash.features.userprofile.R @@ -30,7 +32,8 @@ import kotlin.time.Duration.Companion.milliseconds @HiltViewModel class NameEntryViewModel @Inject constructor( - userManager: UserManager, + private val userManager: UserManager, + private val analytics: FlipcashAnalyticsService, private val moderationController: ModerationController, private val profileController: ProfileController, private val resources: ResourceHelper, @@ -48,7 +51,7 @@ class NameEntryViewModel @Inject constructor( } sealed interface Event { - data object CheckName : Event + data class CheckName(val source: DisplayNameSource) : Event data class UpdateProcessingState( val loading: Boolean = false, val success: Boolean = false @@ -68,10 +71,19 @@ class NameEntryViewModel @Inject constructor( eventFlow .filterIsInstance() - .map { stateFlow.value.nameFieldState.text.toString() } .onEach { dispatchEvent(Event.UpdateProcessingState(loading = true)) } - .map { - profileController.setDisplayName(stateFlow.value.nameFieldState.text.toString()) + .map { event -> + // Read the prior name BEFORE the write. The profile flow above + // pushes the stored name into the field, so the field itself + // cannot tell us whether one already existed. + val hadPreviousName = !userManager.profile?.displayName.isNullOrBlank() + val result = profileController.setDisplayName( + stateFlow.value.nameFieldState.text.toString() + ) + result.onSuccess { + analytics.displayNameSubmitted(event.source, hadPreviousName) + } + result }.onResult( onSuccess = { viewModelScope.launch { @@ -148,7 +160,7 @@ class NameEntryViewModel @Inject constructor( companion object { private val updateStateForEvent: (Event) -> (State.() -> State) = { event -> when (event) { - Event.CheckName -> { state -> state } + is Event.CheckName -> { state -> state } is Event.UpdateProcessingState -> { state -> val current = state.processingState state.copy( diff --git a/apps/flipcash/shared/analytics/build.gradle.kts b/apps/flipcash/shared/analytics/build.gradle.kts index 8d62aaecdc..bd70f1bf4e 100644 --- a/apps/flipcash/shared/analytics/build.gradle.kts +++ b/apps/flipcash/shared/analytics/build.gradle.kts @@ -7,6 +7,9 @@ android { } dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + implementation(platform(libs.firebase.bom)) implementation(libs.firebase.messaging) implementation(libs.bugsnag) 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 4696d2c6a3..aa875c92e4 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,8 +1,10 @@ package com.flipcash.app.analytics import androidx.compose.runtime.Composable +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.internal.model.thirdparty.OnRampProvider +import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.libs.analytics.AnalyticsService @@ -44,6 +46,15 @@ interface FlipcashAnalyticsService : AnalyticsService { fun deeplinkRouted(type: DeeplinkType, error: Throwable? = null) fun displayedErrorModal(title: String, message: String, screen: String? = null, callSite: String? = null) + /** @param hadPreviousName true when the user is replacing a name, false on first set. */ + fun displayNameSubmitted(source: DisplayNameSource, hadPreviousName: Boolean) + + /** Increments a cumulative per-user counter. [amount] defaults to a single occurrence. */ + fun incrementReceivedCounter(counter: Analytics.ReceivedCounter, amount: Double = 1.0) + + fun tipReceived(chatType: ChatType, amount: Fiat, mint: Mint) + fun messageReceived(chatType: ChatType) + fun buttonTapped(button: Button) { action(button) } @@ -51,6 +62,15 @@ interface FlipcashAnalyticsService : AnalyticsService { 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 @@ -67,7 +87,7 @@ object Analytics { } data object SentCash : Transfer - data object SentTip : Transfer + data class SentTip(val origin: TipOrigin) : Transfer } enum class OnrampSource { Settings, Balance, Give } enum class AddMoneySource { Menu, GiveShortfall, BuyShortfall, Chat, Scanner, Balance } @@ -126,6 +146,10 @@ class StubFlipcashAnalytics : FlipcashAnalyticsService { override fun deeplinkParsed(type: DeeplinkType?, url: String) = Unit override fun deeplinkRouted(type: DeeplinkType, error: Throwable?) = Unit override fun displayedErrorModal(title: String, message: String, screen: String?, callSite: String?) = Unit + override fun displayNameSubmitted(source: DisplayNameSource, hadPreviousName: Boolean) = Unit + override fun incrementReceivedCounter(counter: Analytics.ReceivedCounter, amount: Double) = Unit + override fun tipReceived(chatType: ChatType, amount: Fiat, mint: Mint) = Unit + override fun messageReceived(chatType: ChatType) = Unit } @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 6d55248fba..e6f96b6cce 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,10 @@ package com.flipcash.app.analytics import androidx.core.net.toUri +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.internal.model.thirdparty.OnRampProvider +import com.flipcash.services.models.TipOrigin import com.flipcash.services.models.chat.ChatType import com.getcode.ed25519.Ed25519.KeyPair import com.getcode.opencode.model.core.ID @@ -18,6 +20,21 @@ 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, @@ -134,8 +151,9 @@ internal sealed interface AnalyticsEvent { override val name = "Receive Cash Link" } - data object SentTip : Transfer { + data class SentTip(val origin: TipOrigin) : Transfer { override val name = "Sent Tip" + override fun toProperties() = mapOf("Origin" to origin.propertyValue) } data object SentCash : ChatEvent { @@ -154,6 +172,25 @@ internal sealed interface AnalyticsEvent { 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) + } } sealed interface TipCardEvent : AnalyticsEvent { @@ -414,5 +451,25 @@ internal fun Analytics.Transfer.toAnalyticsEvent(): AnalyticsEvent = when (this) 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 -} \ No newline at end of file + is Analytics.Transfer.SentTip -> AnalyticsEvent.SentTip(origin = origin) +} + +internal val TipOrigin.propertyValue: String + get() = when (this) { + TipOrigin.TIPCARD -> "Tipcard" + TipOrigin.CHAT -> "Chat" + } + +internal val DisplayNameSource.propertyValue: String + get() = when (this) { + DisplayNameSource.Onboarding -> "Onboarding" + DisplayNameSource.MyAccount -> "My Account" + DisplayNameSource.TipCardSetup -> "Tip Card Setup" + } + +internal val Analytics.ReceivedCounter.propertyValue: String + get() = when (this) { + Analytics.ReceivedCounter.Tips -> "Tips Received" + Analytics.ReceivedCounter.TipsValue -> "Tips Received Value" + Analytics.ReceivedCounter.Messages -> "Messages Received" + } diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/TokenSymbolResolver.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/TokenSymbolResolver.kt new file mode 100644 index 0000000000..58a68e8982 --- /dev/null +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/TokenSymbolResolver.kt @@ -0,0 +1,18 @@ +package com.flipcash.app.analytics + +/** + * Resolves a mint address to its ticker symbol for analytics properties. + * + * Declared here rather than depending on `:apps:flipcash:shared:tokens` directly: + * that module already depends on this one, so the reverse edge would be a Gradle + * cycle. The real implementation is bound in `:apps:flipcash:app`. + */ +fun interface TokenSymbolResolver { + /** @return the ticker, or null when the mint is not cached. */ + fun symbolFor(mintBase58: String): String? + + companion object { + /** Resolves nothing. Used where analytics is stubbed. */ + val None = TokenSymbolResolver { null } + } +} 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 3ea9b51846..cfea26593e 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/inject/AnalyticsModule.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/inject/AnalyticsModule.kt @@ -1,6 +1,7 @@ package com.flipcash.app.analytics.inject 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 import dagger.Module @@ -13,6 +14,7 @@ import dagger.hilt.components.SingletonComponent object AnalyticsModule { @Provides fun providesAnalyticsService( - mixpanelAPI: MixpanelAPI - ): FlipcashAnalyticsService = MixpanelAnalyticsDelegate(mixpanelAPI) -} \ No newline at end of file + mixpanelAPI: MixpanelAPI, + tokenSymbolResolver: TokenSymbolResolver, + ): FlipcashAnalyticsService = 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 c0217be321..7b00ef0a4c 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 @@ -3,8 +3,11 @@ package com.flipcash.app.analytics.internal import com.flipcash.app.analytics.Analytics import com.flipcash.app.analytics.AnalyticsEvent import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.analytics.TokenSymbolResolver import com.flipcash.app.analytics.asProperties +import com.flipcash.app.analytics.propertyValue import com.flipcash.app.analytics.toAnalyticsEvent +import com.flipcash.app.core.DisplayNameSource import com.flipcash.app.core.navigation.DeeplinkType import com.flipcash.services.internal.model.thirdparty.OnRampProvider import com.flipcash.services.models.chat.ChatType @@ -26,7 +29,8 @@ import org.json.JSONObject import javax.inject.Inject internal class MixpanelAnalyticsDelegate @Inject constructor( - private val mixpanelAPI: MixpanelAPI + private val mixpanelAPI: MixpanelAPI, + private val tokenSymbolResolver: TokenSymbolResolver, ) : FlipcashAnalyticsService { private var traceAppInit: Trace? = null @@ -267,6 +271,27 @@ 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) + } + + 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) { @@ -274,9 +299,19 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( track(event.name, *properties) } + private fun increment(property: String, amount: Double) { + if (BuildConfig.DEBUG) { + trace("debug increment $property by $amount", type = TraceType.Silent) + return + } + mixpanelAPI.people.increment(property, amount) + } + private fun track(name: String, vararg properties: Pair) { + val resolved = properties.toList().withTokenSymbols(tokenSymbolResolver) + if (BuildConfig.DEBUG) { - val propsString = properties.joinToString { "${it.first} => ${it.second}" } + val propsString = resolved.joinToString { "${it.first} => ${it.second}" } trace( buildString { append("debug track $name") @@ -288,11 +323,35 @@ internal class MixpanelAnalyticsDelegate @Inject constructor( } val jsonObject = JSONObject() - properties.forEach { jsonObject.put(it.first, it.second) } + 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 -} \ No newline at end of file +} + +/** Mint-carrying property → the symbol property that accompanies it. */ +private val MINT_PROPERTIES = mapOf( + "Mint" to "Token Symbol", + "Payment Mint" to "Payment Token Symbol", +) + +/** + * 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 new file mode 100644 index 0000000000..a4092d0eb0 --- /dev/null +++ b/apps/flipcash/shared/analytics/src/test/kotlin/com/flipcash/app/analytics/TokenSymbolPropertyTest.kt @@ -0,0 +1,59 @@ +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/build.gradle.kts b/apps/flipcash/shared/chat/build.gradle.kts index 7208f63cbe..89adabc474 100644 --- a/apps/flipcash/shared/chat/build.gradle.kts +++ b/apps/flipcash/shared/chat/build.gradle.kts @@ -20,6 +20,7 @@ dependencies { implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":apps:flipcash:shared:tokens")) + implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":services:flipcash")) implementation(project(":libs:network:connectivity:public")) implementation(libs.androidx.lifecycle.process) diff --git a/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt b/apps/flipcash/shared/chat/src/main/kotlin/com/flipcash/shared/chat/internal/delegates/EventStreamDelegate.kt index f50aee5a12..7377693d5a 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,5 +1,7 @@ package com.flipcash.shared.chat.internal.delegates +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource @@ -7,6 +9,7 @@ import com.flipcash.app.tokens.TokenCoordinator import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.controllers.EventStreamingController import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatUpdate import com.flipcash.services.models.chat.EmojiReaction import com.flipcash.services.models.chat.MessageContent @@ -21,6 +24,7 @@ import com.flipcash.shared.chat.EventSequenceTracker import com.flipcash.shared.chat.EventStreamOperations import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.services.user.UserManager +import com.getcode.opencode.exchange.Exchange import com.getcode.utils.TraceType import com.getcode.utils.trace import kotlinx.coroutines.CoroutineScope @@ -75,6 +79,8 @@ class EventStreamDelegate @Inject constructor( private val tokenCoordinator: TokenCoordinator, private val userManager: UserManager, private val stateHolder: ChatStateHolder, + private val analytics: FlipcashAnalyticsService, + private val exchange: Exchange, ) : EventStreamOperations { companion object { @@ -110,6 +116,32 @@ class EventStreamDelegate @Inject constructor( // region Internal + /** + * Increments the per-user received counters for one inbound message. + * + * A tip is also a message, so a tip increments both counters — `Messages + * Received` is a total, not a non-tip remainder. + */ + private fun countReceipt(msg: ChatMessage) { + analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Messages) + + val tip = msg.content + .filterIsInstance() + .firstOrNull { it.action == MessageContent.Cash.Action.TIPPED } + ?: return + + analytics.incrementReceivedCounter(Analytics.ReceivedCounter.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 + // recoverable, a wrong one is permanent and unattributable. + val usd = exchange.rateToUsd(tip.amount.currencyCode) + ?.let { tip.amount.convertingTo(it) } + ?: return + + analytics.incrementReceivedCounter(Analytics.ReceivedCounter.TipsValue, usd.decimalValue) + } + internal fun initialize(scope: CoroutineScope) { this.scope = scope } @@ -299,9 +331,12 @@ class EventStreamDelegate @Inject constructor( } } - // --- Eagerly update token balance for incoming cash --- + // --- Eagerly update token balance + count receipts for analytics --- val selfId = userManager.accountId + val countedThrough = metadataDataSource.getAnalyticsCountedThrough(chatId) + var highestCounted = countedThrough + for (msg in resolvedMessages) { if (msg.senderId == selfId) continue for (content in msg.content) { @@ -309,6 +344,17 @@ class EventStreamDelegate @Inject constructor( tokenCoordinator.add(content.mint, content.amount) } } + + // people.increment is cumulative and has no message identity, so a + // replay would inflate the counter permanently. The watermark is the + // only thing standing between gap fill and a corrupted profile. + if (msg.messageId <= countedThrough) continue + countReceipt(msg) + highestCounted = maxOf(highestCounted, msg.messageId) + } + + if (highestCounted > countedThrough) { + metadataDataSource.advanceAnalyticsCountedThrough(chatId, highestCounted) } // --- Unknown chat → full feed sync --- 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 cbe0942cfe..c75d48d142 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,6 +8,7 @@ import androidx.paging.Pager import androidx.paging.PagingConfig import androidx.paging.PagingData import androidx.paging.map +import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.persistence.sources.ChatMemberDataSource import com.flipcash.app.persistence.sources.ChatMessageDataSource import com.flipcash.app.persistence.sources.ChatMetadataDataSource @@ -17,6 +18,7 @@ import com.flipcash.services.controllers.ChatMessagingController import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessageContent import com.flipcash.services.models.chat.MessagePointer import com.flipcash.services.models.chat.PointerType @@ -24,6 +26,7 @@ import com.flipcash.services.models.chat.TypingState import com.flipcash.shared.chat.MessagingOperations import com.flipcash.shared.chat.internal.ChatStateHolder import com.flipcash.services.user.UserManager +import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map @@ -57,6 +60,7 @@ class MessagingDelegate @Inject constructor( private val notificationManager: NotificationManagerCompat, private val userManager: UserManager, private val stateHolder: ChatStateHolder, + private val analytics: FlipcashAnalyticsService, ) : MessagingOperations { // region MessagingOperations @@ -178,6 +182,11 @@ class MessagingDelegate @Inject constructor( IllegalStateException("No account") ) + // Report before the write, while the previous pointer is still readable. + // Crossing the pointer is the only moment a message is unambiguously + // "received" by the user rather than merely delivered to the device. + reportCrossedMessages(chatId, selfId, messageId) + val pointer = MessagePointer( type = PointerType.READ, userId = selfId, @@ -207,6 +216,38 @@ class MessagingDelegate @Inject constructor( // region Internal + /** + * Emits one received event per inbound message the read pointer is about to + * cross. A non-advancing pointer (a re-read, or a backwards jump from an + * out-of-order caller) crosses nothing and emits nothing. + */ + private suspend fun reportCrossedMessages(chatId: ChatId, selfId: ID, messageId: Long) { + val previous = memberDataSource.getSelfReadPointer(chatId, selfId) + if (messageId <= previous) return + + val crossed = messageDataSource.getInboundMessagesInRange( + chatId = chatId, + selfId = selfId, + afterId = previous, + throughId = messageId, + ) + if (crossed.isEmpty()) return + + val chatType = metadataDataSource.getChatType(chatId) + + for (msg in crossed) { + val tip = msg.content + .filterIsInstance() + .firstOrNull { it.action == MessageContent.Cash.Action.TIPPED } + + if (tip != null) { + analytics.tipReceived(chatType, tip.amount, tip.mint) + } else { + analytics.messageReceived(chatType) + } + } + } + internal suspend fun clear() { metadataDataSource.clear() messageDataSource.clear() diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt index dc8b210d7b..2a6025e0ee 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEagerBalanceTest.kt @@ -97,6 +97,8 @@ class ChatCoordinatorEagerBalanceTest { tokenCoordinator = tokenCoordinator, userManager = userManager, stateHolder = stateHolder, + analytics = mockk(relaxed = true), + exchange = mockk(relaxed = true), ) val messagingDelegate = MessagingDelegate( @@ -108,6 +110,7 @@ class ChatCoordinatorEagerBalanceTest { notificationManager = mockk(relaxed = true), userManager = userManager, stateHolder = stateHolder, + analytics = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( diff --git a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt index a9ee5dae71..1a83953299 100644 --- a/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ChatCoordinatorEventsTest.kt @@ -102,6 +102,8 @@ class ChatCoordinatorEventsTest { tokenCoordinator = mockk(relaxed = true), userManager = userManager, stateHolder = stateHolder, + analytics = mockk(relaxed = true), + exchange = mockk(relaxed = true), ) val messagingDelegate = MessagingDelegate( @@ -113,6 +115,7 @@ class ChatCoordinatorEventsTest { notificationManager = mockk(relaxed = true), userManager = userManager, stateHolder = stateHolder, + analytics = mockk(relaxed = true), ) val dmChatResolverDelegate = DmChatResolverDelegate( 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 new file mode 100644 index 0000000000..0fed87915e --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedCounterTest.kt @@ -0,0 +1,273 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.analytics.Analytics +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatIdGenerator +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.RealChatCoordinator +import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate +import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.opencode.exchange.Exchange +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.utils.network.NetworkConnectivityListener +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ReceivedCounterTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val chatId = ChatId("aabbccdd") + private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") + + private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) + + private lateinit var analytics: FlipcashAnalyticsService + private lateinit var metadataDataSource: ChatMetadataDataSource + private lateinit var exchange: Exchange + private lateinit var coordinator: RealChatCoordinator + private lateinit var testDispatchers: TestDispatchers + + /** Watermark the fake metadata source will report. */ + private var countedThrough: Long = 0L + + @Before + fun setUp() { + analytics = mockk(relaxed = true) + 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) + + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + + val eventStreamingController = mockk(relaxed = true) + every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow() + every { eventStreamingController.isConnected } returns true + every { eventStreamingController.isStreamActive } returns true + + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed(any(), any()) } returns + Result.failure(RuntimeException("not needed")) + + testDispatchers = TestDispatchers(TestCoroutineScheduler()) + + val stateHolder = ChatStateHolder() + val memberDataSource = mockk(relaxed = true) + val messagingController = mockk(relaxed = true) + val messageDataSource = mockk(relaxed = true) + + metadataDataSource = mockk(relaxed = true) + coEvery { metadataDataSource.getAnalyticsCountedThrough(any()) } answers { countedThrough } + coEvery { metadataDataSource.advanceAnalyticsCountedThrough(any(), any()) } answers { + countedThrough = maxOf(countedThrough, secondArg()) + } + + val feedDelegate = FeedSyncDelegate( + chatController = chatController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = stateHolder, + userManager = userManager, + ) + + val eventStreamDelegate = EventStreamDelegate( + eventStreamingController = eventStreamingController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + tokenCoordinator = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = analytics, + exchange = exchange, + ) + + val messagingDelegate = MessagingDelegate( + chatController = chatController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = analytics, + ) + + val dmChatResolverDelegate = DmChatResolverDelegate( + chatIdGenerator = ChatIdGenerator(), + userManager = userManager, + contactDataSource = mockk(relaxed = true), + memberDataSource = memberDataSource, + ) + + coordinator = RealChatCoordinator( + feedDelegate = feedDelegate, + eventStreamDelegate = eventStreamDelegate, + dmChatResolverDelegate = dmChatResolverDelegate, + messagingDelegate = messagingDelegate, + stateHolder = stateHolder, + userManager = userManager, + networkObserver = mockk(relaxed = true), + dispatchers = testDispatchers, + ) + } + + private fun tipMessage( + messageId: Long, + senderId: List?, + amount: Fiat = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD), + ) = ChatMessage( + messageId = messageId, + senderId = senderId, + content = listOf( + MessageContent.Cash( + intentId = listOf(0), + amount = amount, + mint = mint, + action = MessageContent.Cash.Action.TIPPED, + ) + ), + timestamp = Instant.fromEpochSeconds(1000), + unreadSeq = 0, + ) + + private fun textMessage(messageId: Long, senderId: List?) = ChatMessage( + messageId = messageId, + senderId = senderId, + content = listOf(MessageContent.Text("hello")), + timestamp = Instant.fromEpochSeconds(1000), + unreadSeq = 0, + ) + + // newMessages is deprecated in favour of `events`, but applyUpdate still falls + // back to it and every existing chat test builds updates this way. Matching the + // existing harness keeps these tests readable next to their neighbours. + private fun chatUpdate(vararg messages: ChatMessage) = ChatUpdate( + chatId = chatId, + newMessages = messages.toList(), + pointerUpdates = emptyList(), + typingNotifications = emptyList(), + metadataUpdates = emptyList(), + ) + + private suspend fun TestScope.deliver(vararg messages: ChatMessage) { + chatUpdatesChannel.send(chatUpdate(*messages)) + advanceTimeBy(1_000.milliseconds) + runCurrent() + } + + @Test + fun `inbound tip increments tips and messages`() = runTest(testDispatchers.dispatcher) { + 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) } + coordinator.reset() + } + + @Test + fun `inbound text increments messages only`() = runTest(testDispatchers.dispatcher) { + 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()) } + coordinator.reset() + } + + @Test + fun `self-sent messages are not counted`() = runTest(testDispatchers.dispatcher) { + coordinator.onUserLoggedIn(mockk(relaxed = true)) + deliver(tipMessage(messageId = 1L, senderId = selfId)) + + coVerify(exactly = 0) { analytics.incrementReceivedCounter(any(), any()) } + coordinator.reset() + } + + @Test + fun `redelivered messages are counted exactly once`() = runTest(testDispatchers.dispatcher) { + coordinator.onUserLoggedIn(mockk(relaxed = true)) + val msg = tipMessage(messageId = 7L, senderId = otherId) + + deliver(msg) + deliver(msg) // gap fill / reconnect replays the same message + + coVerify(exactly = 1) { analytics.incrementReceivedCounter(Analytics.ReceivedCounter.Tips, 1.0) } + coordinator.reset() + } + + @Test + fun `messages at or below the watermark are skipped`() = runTest(testDispatchers.dispatcher) { + countedThrough = 10L + coordinator.onUserLoggedIn(mockk(relaxed = true)) + deliver(textMessage(messageId = 10L, senderId = otherId)) + + coVerify(exactly = 0) { analytics.incrementReceivedCounter(any(), any()) } + coordinator.reset() + } + + @Test + fun `tip value is normalised to USD`() = runTest(testDispatchers.dispatcher) { + coordinator.onUserLoggedIn(mockk(relaxed = true)) + 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) } + coordinator.reset() + } + + @Test + fun `missing rate still counts the tip but not its value`() = runTest(testDispatchers.dispatcher) { + every { exchange.rateToUsd(CurrencyCode.EUR) } returns null + coordinator.onUserLoggedIn(mockk(relaxed = true)) + 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()) } + coordinator.reset() + } +} 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 new file mode 100644 index 0000000000..ba845a3f6e --- /dev/null +++ b/apps/flipcash/shared/chat/src/test/kotlin/com/flipcash/shared/chat/ReceivedEventTest.kt @@ -0,0 +1,266 @@ +package com.flipcash.shared.chat + +import com.flipcash.app.analytics.FlipcashAnalyticsService +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.persistence.sources.ChatMemberDataSource +import com.flipcash.app.persistence.sources.ChatMessageDataSource +import com.flipcash.app.persistence.sources.ChatMetadataDataSource +import com.flipcash.app.persistence.sources.ContactDataSource +import com.flipcash.app.tokens.TokenCoordinator +import com.flipcash.services.controllers.ChatController +import com.flipcash.services.controllers.ChatMessagingController +import com.flipcash.services.controllers.EventStreamingController +import com.flipcash.services.models.chat.ChatId +import com.flipcash.services.models.chat.ChatMessage +import com.flipcash.services.models.chat.ChatType +import com.flipcash.services.models.chat.ChatUpdate +import com.flipcash.services.models.chat.MessageContent +import com.flipcash.services.user.UserManager +import com.flipcash.shared.chat.internal.ChatIdGenerator +import com.flipcash.shared.chat.internal.ChatStateHolder +import com.flipcash.shared.chat.internal.RealChatCoordinator +import com.flipcash.shared.chat.internal.delegates.DmChatResolverDelegate +import com.flipcash.shared.chat.internal.delegates.EventStreamDelegate +import com.flipcash.shared.chat.internal.delegates.FeedSyncDelegate +import com.flipcash.shared.chat.internal.delegates.MessagingDelegate +import com.getcode.opencode.exchange.Exchange +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.utils.network.NetworkConnectivityListener +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.flow.receiveAsFlow +import kotlinx.coroutines.test.TestCoroutineScheduler +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceTimeBy +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.time.Duration.Companion.milliseconds +import kotlin.time.Instant + +@OptIn(ExperimentalCoroutinesApi::class) +@RunWith(RobolectricTestRunner::class) +class ReceivedEventTest { + + private val selfId = listOf(1, 2, 3) + private val otherId = listOf(4, 5, 6) + private val chatId = ChatId("aabbccdd") + private val mint = Mint("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaaaaaaaaaaa") + + private val chatUpdatesChannel = Channel(capacity = Channel.UNLIMITED) + + private lateinit var analytics: FlipcashAnalyticsService + private lateinit var metadataDataSource: ChatMetadataDataSource + private lateinit var memberDataSource: ChatMemberDataSource + private lateinit var messageDataSource: ChatMessageDataSource + private lateinit var exchange: Exchange + private lateinit var coordinator: RealChatCoordinator + private lateinit var testDispatchers: TestDispatchers + + /** Watermark the fake metadata source will report. */ + private var countedThrough: Long = 0L + + @Before + fun setUp() { + analytics = mockk(relaxed = true) + 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) + + val userManager = mockk(relaxed = true) + every { userManager.accountId } returns selfId + + val eventStreamingController = mockk(relaxed = true) + every { eventStreamingController.chatUpdates } returns chatUpdatesChannel.receiveAsFlow() + every { eventStreamingController.isConnected } returns true + every { eventStreamingController.isStreamActive } returns true + + val chatController = mockk(relaxed = true) + coEvery { chatController.getDmChatFeed(any(), any()) } returns + Result.failure(RuntimeException("not needed")) + + testDispatchers = TestDispatchers(TestCoroutineScheduler()) + + val stateHolder = ChatStateHolder() + memberDataSource = mockk(relaxed = true) + val messagingController = mockk(relaxed = true) + messageDataSource = mockk(relaxed = true) + + metadataDataSource = mockk(relaxed = true) + coEvery { metadataDataSource.getAnalyticsCountedThrough(any()) } answers { countedThrough } + coEvery { metadataDataSource.advanceAnalyticsCountedThrough(any(), any()) } answers { + countedThrough = maxOf(countedThrough, secondArg()) + } + coEvery { metadataDataSource.getChatType(chatId) } returns ChatType.TIP_DM + + val feedDelegate = FeedSyncDelegate( + chatController = chatController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + stateHolder = stateHolder, + userManager = userManager, + ) + + val eventStreamDelegate = EventStreamDelegate( + eventStreamingController = eventStreamingController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + tokenCoordinator = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = analytics, + exchange = exchange, + ) + + val messagingDelegate = MessagingDelegate( + chatController = chatController, + messagingController = messagingController, + metadataDataSource = metadataDataSource, + messageDataSource = messageDataSource, + memberDataSource = memberDataSource, + notificationManager = mockk(relaxed = true), + userManager = userManager, + stateHolder = stateHolder, + analytics = analytics, + ) + + val dmChatResolverDelegate = DmChatResolverDelegate( + chatIdGenerator = ChatIdGenerator(), + userManager = userManager, + contactDataSource = mockk(relaxed = true), + memberDataSource = memberDataSource, + ) + + coordinator = RealChatCoordinator( + feedDelegate = feedDelegate, + eventStreamDelegate = eventStreamDelegate, + dmChatResolverDelegate = dmChatResolverDelegate, + messagingDelegate = messagingDelegate, + stateHolder = stateHolder, + userManager = userManager, + networkObserver = mockk(relaxed = true), + dispatchers = testDispatchers, + ) + } + + private fun tipMessage( + messageId: Long, + senderId: List?, + amount: Fiat = Fiat(fiat = 5.0, currencyCode = CurrencyCode.USD), + ) = ChatMessage( + messageId = messageId, + senderId = senderId, + content = listOf( + MessageContent.Cash( + intentId = listOf(0), + amount = amount, + mint = mint, + action = MessageContent.Cash.Action.TIPPED, + ) + ), + timestamp = Instant.fromEpochSeconds(1000), + unreadSeq = 0, + ) + + private fun textMessage(messageId: Long, senderId: List?) = ChatMessage( + messageId = messageId, + senderId = senderId, + content = listOf(MessageContent.Text("hello")), + timestamp = Instant.fromEpochSeconds(1000), + unreadSeq = 0, + ) + + // newMessages is deprecated in favour of `events`, but applyUpdate still falls + // back to it and every existing chat test builds updates this way. Matching the + // existing harness keeps these tests readable next to their neighbours. + private fun chatUpdate(vararg messages: ChatMessage) = ChatUpdate( + chatId = chatId, + newMessages = messages.toList(), + pointerUpdates = emptyList(), + typingNotifications = emptyList(), + metadataUpdates = emptyList(), + ) + + private suspend fun TestScope.deliver(vararg messages: ChatMessage) { + chatUpdatesChannel.send(chatUpdate(*messages)) + advanceTimeBy(1_000.milliseconds) + runCurrent() + } + + @Test + fun `crossing inbound messages emits one event each`() = runTest(testDispatchers.dispatcher) { + coEvery { memberDataSource.getSelfReadPointer(chatId, selfId) } returns 0L + coEvery { + messageDataSource.getInboundMessagesInRange(chatId, selfId, 0L, 3L) + } returns listOf( + textMessage(messageId = 1L, senderId = otherId), + textMessage(messageId = 2L, senderId = otherId), + textMessage(messageId = 3L, senderId = otherId), + ) + + coordinator.advanceReadPointer(chatId, 3L) + + coVerify(exactly = 3) { analytics.messageReceived(ChatType.TIP_DM) } + } + + @Test + fun `a crossed tip emits Tip Received and not Message Received`() = + runTest(testDispatchers.dispatcher) { + coEvery { memberDataSource.getSelfReadPointer(chatId, selfId) } returns 0L + coEvery { + messageDataSource.getInboundMessagesInRange(chatId, selfId, 0L, 1L) + } returns listOf(tipMessage(messageId = 1L, senderId = otherId)) + + coordinator.advanceReadPointer(chatId, 1L) + + coVerify(exactly = 1) { analytics.tipReceived(ChatType.TIP_DM, any(), any()) } + coVerify(exactly = 0) { analytics.messageReceived(any()) } + } + + @Test + fun `a backward advance emits nothing`() = runTest(testDispatchers.dispatcher) { + coEvery { memberDataSource.getSelfReadPointer(chatId, selfId) } returns 10L + + coordinator.advanceReadPointer(chatId, 5L) + + coVerify(exactly = 0) { analytics.messageReceived(any()) } + coVerify(exactly = 0) { analytics.tipReceived(any(), any(), any()) } + } + + @Test + fun `a repeated advance to the same pointer emits nothing`() = + runTest(testDispatchers.dispatcher) { + coEvery { memberDataSource.getSelfReadPointer(chatId, selfId) } returns 3L + + coordinator.advanceReadPointer(chatId, 3L) + + coVerify(exactly = 0) { analytics.messageReceived(any()) } + } + + @Test + fun `outbound messages in the range are not reported`() = + runTest(testDispatchers.dispatcher) { + coEvery { memberDataSource.getSelfReadPointer(chatId, selfId) } returns 0L + // The DAO filters by sender, so an all-outbound range comes back empty. + coEvery { + messageDataSource.getInboundMessagesInRange(chatId, selfId, 0L, 2L) + } returns emptyList() + + coordinator.advanceReadPointer(chatId, 2L) + + coVerify(exactly = 0) { analytics.messageReceived(any()) } + } +} diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/30.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/30.json new file mode 100644 index 0000000000..448f0189b3 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/30.json @@ -0,0 +1,763 @@ +{ + "formatVersion": 1, + "database": { + "version": 30, + "identityHash": "69d8e95dd4fc3b0aafcf9421ae154fdd", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', `textSubstitutions` TEXT, PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + }, + { + "fieldPath": "textSubstitutions", + "columnName": "textSubstitutions", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `market_cap_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "marketCapMetricsJson", + "columnName": "market_cap_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, `hasDiscoveredFlipcashContacts` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "hasDiscoveredFlipcashContacts", + "columnName": "hasDiscoveredFlipcashContacts", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, `displayNumber` TEXT NOT NULL DEFAULT '', `dmChatId` TEXT NOT NULL DEFAULT '', `joinedAtEpochSeconds` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayNumber", + "columnName": "displayNumber", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "dmChatId", + "columnName": "dmChatId", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "''" + }, + { + "fieldPath": "joinedAtEpochSeconds", + "columnName": "joinedAtEpochSeconds", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + }, + { + "tableName": "chat_metadata", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `chat_type` TEXT NOT NULL, `last_activity_epoch_ms` INTEGER NOT NULL, `last_message_id` INTEGER, `latest_event_sequence` INTEGER NOT NULL DEFAULT 0, `is_hidden` INTEGER NOT NULL DEFAULT 0, `analytics_counted_through` INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(`chat_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "chatType", + "columnName": "chat_type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lastActivityEpochMs", + "columnName": "last_activity_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastMessageId", + "columnName": "last_message_id", + "affinity": "INTEGER" + }, + { + "fieldPath": "latestEventSequence", + "columnName": "latest_event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "isHidden", + "columnName": "is_hidden", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "analyticsCountedThrough", + "columnName": "analytics_counted_through", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex" + ] + }, + "indices": [ + { + "name": "index_chat_metadata_last_activity_epoch_ms", + "unique": false, + "columnNames": [ + "last_activity_epoch_ms" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_chat_metadata_last_activity_epoch_ms` ON `${TABLE_NAME}` (`last_activity_epoch_ms`)" + } + ] + }, + { + "tableName": "chat_messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `message_id` INTEGER NOT NULL, `sender_id_hex` TEXT, `content_json` TEXT, `timestamp_epoch_ms` INTEGER NOT NULL, `unread_seq` INTEGER NOT NULL, `status` TEXT NOT NULL DEFAULT 'SENT', `pending_client_id_hex` TEXT, `event_sequence` INTEGER NOT NULL DEFAULT 0, `last_edited_ts_epoch_ms` INTEGER, `reactions_json` TEXT, PRIMARY KEY(`chat_id_hex`, `message_id`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "messageId", + "columnName": "message_id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "senderIdHex", + "columnName": "sender_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "contentJson", + "columnName": "content_json", + "affinity": "TEXT" + }, + { + "fieldPath": "timestampEpochMs", + "columnName": "timestamp_epoch_ms", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "unreadSeq", + "columnName": "unread_seq", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "status", + "columnName": "status", + "affinity": "TEXT", + "notNull": true, + "defaultValue": "'SENT'" + }, + { + "fieldPath": "pendingClientIdHex", + "columnName": "pending_client_id_hex", + "affinity": "TEXT" + }, + { + "fieldPath": "eventSequence", + "columnName": "event_sequence", + "affinity": "INTEGER", + "notNull": true, + "defaultValue": "0" + }, + { + "fieldPath": "lastEditedTsEpochMs", + "columnName": "last_edited_ts_epoch_ms", + "affinity": "INTEGER" + }, + { + "fieldPath": "reactionsJson", + "columnName": "reactions_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "message_id" + ] + } + }, + { + "tableName": "chat_members", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`chat_id_hex` TEXT NOT NULL, `user_id_hex` TEXT NOT NULL, `pointers_json` TEXT, PRIMARY KEY(`chat_id_hex`, `user_id_hex`))", + "fields": [ + { + "fieldPath": "chatIdHex", + "columnName": "chat_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "pointersJson", + "columnName": "pointers_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "chat_id_hex", + "user_id_hex" + ] + } + }, + { + "tableName": "blocked_users", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `blocked_at_epoch_ms` INTEGER NOT NULL, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "blockedAtEpochMs", + "columnName": "blocked_at_epoch_ms", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + }, + { + "tableName": "user_profiles", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`user_id_hex` TEXT NOT NULL, `display_name` TEXT NOT NULL, `phone_value` TEXT, `phone_verified` INTEGER, `email_value` TEXT, `email_verified` INTEGER, `social_accounts_json` TEXT, `profile_picture_json` TEXT, `pending_migration_json` TEXT, PRIMARY KEY(`user_id_hex`))", + "fields": [ + { + "fieldPath": "userIdHex", + "columnName": "user_id_hex", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "display_name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "phoneValue", + "columnName": "phone_value", + "affinity": "TEXT" + }, + { + "fieldPath": "phoneVerified", + "columnName": "phone_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "emailValue", + "columnName": "email_value", + "affinity": "TEXT" + }, + { + "fieldPath": "emailVerified", + "columnName": "email_verified", + "affinity": "INTEGER" + }, + { + "fieldPath": "socialAccounts", + "columnName": "social_accounts_json", + "affinity": "TEXT" + }, + { + "fieldPath": "profilePicture", + "columnName": "profile_picture_json", + "affinity": "TEXT" + }, + { + "fieldPath": "pendingMigrationJson", + "columnName": "pending_migration_json", + "affinity": "TEXT" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "user_id_hex" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '69d8e95dd4fc3b0aafcf9421ae154fdd')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index bbbcc16bd6..49a51a7772 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -90,8 +90,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 26, to = 27), // messages.text_substitutions (nullable) AutoMigration(from = 27, to = 28), // tokens.market_cap_metrics (nullable) AutoMigration(from = 28, to = 29, spec = FlipcashDatabase.Migration28To29::class), + AutoMigration(from = 29, to = 30), // chat_metadata.analytics_counted_through ], - version = 29, + version = 30, ) @TypeConverters(TokenTypeConverters::class, ChatTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt index ee693339e5..59f2948878 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMessageDao.kt @@ -29,6 +29,20 @@ interface ChatMessageDao { @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex ORDER BY timestamp_epoch_ms DESC LIMIT 1") suspend fun getLatest(chatIdHex: String): ChatMessageEntity? + @Query( + "SELECT * FROM chat_messages " + + "WHERE chat_id_hex = :chatIdHex " + + "AND sender_id_hex IS NOT NULL AND sender_id_hex != :selfIdHex " + + "AND message_id > :afterId AND message_id <= :throughId " + + "ORDER BY message_id ASC" + ) + suspend fun getInboundMessagesInRange( + chatIdHex: String, + selfIdHex: String, + afterId: Long, + throughId: Long, + ): List + @Query("SELECT * FROM chat_messages WHERE chat_id_hex = :chatIdHex AND pending_client_id_hex = :clientIdHex LIMIT 1") suspend fun getByClientId(chatIdHex: String, clientIdHex: String): ChatMessageEntity? diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt index 97951c9445..b8ab6a3dd9 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ChatMetadataDao.kt @@ -37,6 +37,19 @@ interface ChatMetadataDao { @Query("SELECT latest_event_sequence FROM chat_metadata WHERE chat_id_hex = :chatIdHex") suspend fun getLatestEventSequence(chatIdHex: String): Long? + @Query("SELECT chat_type FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + suspend fun getChatType(chatIdHex: String): String? + + @Query("SELECT analytics_counted_through FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + suspend fun getAnalyticsCountedThrough(chatIdHex: String): Long? + + // MAX() keeps the watermark monotonic even if an out-of-order write lands. + @Query( + "UPDATE chat_metadata SET analytics_counted_through = MAX(analytics_counted_through, :messageId) " + + "WHERE chat_id_hex = :chatIdHex" + ) + suspend fun advanceAnalyticsCountedThrough(chatIdHex: String, messageId: Long) + @Query("UPDATE chat_metadata SET is_hidden = :hidden WHERE chat_id_hex = :chatIdHex") suspend fun updateHidden(chatIdHex: String, hidden: Boolean) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt index 93c6d08216..01af8f5547 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ChatMetadataEntity.kt @@ -12,4 +12,8 @@ data class ChatMetadataEntity( @ColumnInfo(name = "last_message_id") val lastMessageId: Long?, @ColumnInfo(name = "latest_event_sequence", defaultValue = "0") val latestEventSequence: Long = 0, @ColumnInfo(name = "is_hidden", defaultValue = "0") val isHidden: Boolean = false, + // Highest message id already counted into the received-analytics people + // properties. Monotonic; guards non-idempotent increments against replay. + @ColumnInfo(name = "analytics_counted_through", defaultValue = "0") + val analyticsCountedThrough: Long = 0, ) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt index c2ca96fdfe..deb9b9470f 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMemberDataSource.kt @@ -7,6 +7,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatType import com.flipcash.services.models.chat.MessagePointer +import com.flipcash.services.models.chat.PointerType import com.getcode.opencode.model.core.ID import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow @@ -47,6 +48,15 @@ class ChatMemberDataSource @Inject constructor( suspend fun getMembersForChat(chatIdHex: String): List = db?.chatMemberDao()?.getMembersForChat(chatIdHex)?.map { mapper.toMember(it) } ?: emptyList() + /** The READ pointer [selfId] has already advanced to in [chatId], or 0 if none is cached. */ + suspend fun getSelfReadPointer(chatId: ChatId, selfId: ID): Long = + getMembersForChat(chatId) + .firstOrNull { it.userId == selfId } + ?.pointers + ?.firstOrNull { it.type == PointerType.READ } + ?.value + ?: 0L + suspend fun upsert(chatId: ChatId, members: List) { val database = db ?: return val hex = mapper.chatIdHex(chatId) diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt index 1a11959a1e..e651693189 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMessageDataSource.kt @@ -124,6 +124,19 @@ class ChatMessageDataSource @Inject constructor( suspend fun getLatestMessageId(chatId: ChatId): Long? = db?.chatMessageDao()?.getLatest(mapper.chatIdHex(chatId))?.messageId + suspend fun getInboundMessagesInRange( + chatId: ChatId, + selfId: ID, + afterId: Long, + throughId: Long, + ): List = + db?.chatMessageDao()?.getInboundMessagesInRange( + chatIdHex = mapper.chatIdHex(chatId), + selfIdHex = mapper.userIdHex(selfId), + afterId = afterId, + throughId = throughId, + )?.map { toChatMessage(it) }.orEmpty() + suspend fun upsert(chatId: ChatId, messages: List) { val hex = mapper.chatIdHex(chatId) val entities = messages.map { mapper.toEntity(hex, it) } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt index 7f51deab30..71e00eb654 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ChatMetadataDataSource.kt @@ -7,6 +7,7 @@ import com.flipcash.services.models.chat.ChatId import com.flipcash.services.models.chat.ChatMember import com.flipcash.services.models.chat.ChatMessage import com.flipcash.services.models.chat.ChatMetadata +import com.flipcash.services.models.chat.ChatType import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.emptyFlow import kotlinx.coroutines.flow.map @@ -50,6 +51,18 @@ class ChatMetadataDataSource @Inject constructor( suspend fun getLatestEventSequence(chatId: ChatId): Long = db?.chatMetadataDao()?.getLatestEventSequence(mapper.chatIdHex(chatId)) ?: 0L + suspend fun getChatType(chatId: ChatId): ChatType { + val stored = db?.chatMetadataDao()?.getChatType(mapper.chatIdHex(chatId)) + return ChatType.entries.firstOrNull { it.name == stored } ?: ChatType.UNKNOWN + } + + suspend fun getAnalyticsCountedThrough(chatId: ChatId): Long = + db?.chatMetadataDao()?.getAnalyticsCountedThrough(mapper.chatIdHex(chatId)) ?: 0L + + suspend fun advanceAnalyticsCountedThrough(chatId: ChatId, messageId: Long) { + db?.chatMetadataDao()?.advanceAnalyticsCountedThrough(mapper.chatIdHex(chatId), messageId) + } + suspend fun setHidden(chatId: ChatId, hidden: Boolean) { db?.chatMetadataDao()?.updateHidden(mapper.chatIdHex(chatId), hidden) } 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 bec3ef78f1..f8cf6a99e1 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 @@ -204,7 +204,7 @@ class TippingCoordinator @Inject constructor( delay(400.milliseconds) setSendState(LoadingSuccessState()) analytics.transfer( - event = Analytics.Transfer.SentTip, + event = Analytics.Transfer.SentTip(TipOrigin.TIPCARD), amount = verifiedFiat.localFiat, successful = true, ) @@ -217,7 +217,7 @@ class TippingCoordinator @Inject constructor( }.onFailure { cause -> setSendState(LoadingSuccessState()) analytics.transfer( - event = Analytics.Transfer.SentTip, + event = Analytics.Transfer.SentTip(TipOrigin.TIPCARD), amount = verifiedFiat.localFiat, error = cause, ) diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/user/AnalyticsIdentity.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/user/AnalyticsIdentity.kt new file mode 100644 index 0000000000..5c94772ab2 --- /dev/null +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/user/AnalyticsIdentity.kt @@ -0,0 +1,14 @@ +package com.flipcash.services.user + +import com.getcode.opencode.model.core.ID + +/** + * The Mixpanel/Bugsnag distinct id for an account. + * + * Lowercase, unseparated hex — byte-identical to iOS `Data.hexEncodedString()`. + * Both platforms must produce the same string for the same account or a single + * user becomes two analytics profiles. Do not change this encoding without + * changing iOS in the same release. + */ +@OptIn(ExperimentalStdlibApi::class) +fun ID.analyticsDistinctId(): String = toByteArray().toHexString() diff --git a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt index bfd819adf4..c7187b1f78 100644 --- a/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt +++ b/services/flipcash/src/main/kotlin/com/flipcash/services/user/UserManager.kt @@ -16,7 +16,6 @@ import com.getcode.opencode.model.financial.Token import com.getcode.opencode.model.financial.usdf import com.getcode.services.opencode.BuildConfig import com.getcode.utils.TraceManager -import com.getcode.utils.base58 import com.getcode.utils.trace import com.hoc081098.channeleventbus.ChannelEventBus import com.mixpanel.android.mpmetrics.MixpanelAPI @@ -204,8 +203,11 @@ class UserManager @Inject constructor( private fun associate() { if (!BuildConfig.DEBUG) { - TraceManager.userId = accountId?.base58 - mixpanelAPI.identify(accountId?.base58) + // Hex, not base58 — matches iOS so one user is one Mixpanel profile. + // See AnalyticsIdentity.kt. + val distinctId = accountId?.analyticsDistinctId() + TraceManager.userId = distinctId + mixpanelAPI.identify(distinctId) } } diff --git a/services/flipcash/src/test/kotlin/com/flipcash/services/user/AnalyticsIdentityTest.kt b/services/flipcash/src/test/kotlin/com/flipcash/services/user/AnalyticsIdentityTest.kt new file mode 100644 index 0000000000..c8fb0ff434 --- /dev/null +++ b/services/flipcash/src/test/kotlin/com/flipcash/services/user/AnalyticsIdentityTest.kt @@ -0,0 +1,32 @@ +package com.flipcash.services.user + +import org.junit.Assert.assertEquals +import org.junit.Test + +class AnalyticsIdentityTest { + + // iOS Data.hexEncodedString(): lowercase, two zero-padded digits per byte, + // no separators, no 0x prefix. These vectors are the cross-platform contract. + @Test + fun `encodes bytes as lowercase unseparated hex`() { + val id = listOf(0x01, 0x02, 0x03) + assertEquals("010203", id.analyticsDistinctId()) + } + + @Test + fun `zero-pads single digit bytes`() { + val id = listOf(0x00, 0x0f) + assertEquals("000f", id.analyticsDistinctId()) + } + + @Test + fun `encodes high bytes as lowercase without sign extension`() { + val id = listOf(0xff.toByte(), 0xab.toByte()) + assertEquals("ffab", id.analyticsDistinctId()) + } + + @Test + fun `empty id encodes to empty string`() { + assertEquals("", emptyList().analyticsDistinctId()) + } +}