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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.mapNotNull
Expand Down Expand Up @@ -77,6 +78,12 @@ internal class WalletViewModel @Inject constructor(
* short-circuit the wait: if there is already activity to draw, there is nothing to
* mistake for a new account — and neither is a held balance, which is a live read of the
* account rather than of the cache.
*
* The two caches are separate and settle separately. The `feedSyncState` clause covers the
* activity feed; the *chat* cache backing the tip milestone is covered by
* [onboardingItems] staying null, because the milestone flow withholds an answer until its
* own history has hydrated. A held balance deliberately does not short-circuit that one:
* it says nothing about whether the account has tipped.
*/
val isAwaitingActivity: Boolean
get() = onboardingItems == null ||
Expand Down Expand Up @@ -137,14 +144,21 @@ internal class WalletViewModel @Inject constructor(
chatCoordinator.hasEverTipped(),
tokenCoordinator.hasAnyBalance,
) { hasReceivedMoney, hasTipped, holdsBalance ->
Event.OnOnboardingItemsUpdated(
items = listOf(
TutorialItem.AddMoney(isCompleted = hasReceivedMoney || holdsBalance),
TutorialItem.ScanTipCard(isCompleted = hasTipped),
),
holdsBalance = holdsBalance,
)
// A null tip milestone means the chat cache has not been reconciled yet, and an
// un-hydrated cache reports every account as never having tipped. Emitting nothing
// keeps [State.onboardingItems] null, which holds the whole tab on its spinner rather
// than drawing "Scan a Tip Card" as outstanding to someone who already did it.
hasTipped?.let {
Event.OnOnboardingItemsUpdated(
items = listOf(
TutorialItem.AddMoney(isCompleted = hasReceivedMoney || holdsBalance),
TutorialItem.ScanTipCard(isCompleted = it),
),
holdsBalance = holdsBalance,
)
}
}
.filterNotNull()
.onEach { dispatchEvent(it) }
.launchIn(viewModelScope)
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
package com.flipcash.app.balance.internal

import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.flipcash.app.analytics.StubFlipcashAnalytics
import com.flipcash.app.balance.internal.components.TutorialItem
import com.flipcash.app.core.MainCoroutineRule
import com.flipcash.app.core.dispatchers.TestDispatchers
import com.flipcash.app.funding.PurchaseMethodController
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.services.user.UserManager
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.transactionhistory.ActivityFeedCoordinator
import io.mockk.every
import io.mockk.mockk
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertNull
import kotlin.test.assertTrue

/**
* The tip milestone is read off the chat cache, which reports every account as never having tipped
* until its history has been reconciled. The wallet must not draw that: [WalletViewModel.State.
* onboardingItems] stays null — holding the tab on its loading state — until the chat coordinator
* commits to an answer.
*
* A held balance deliberately does not release the wait here, unlike for the activity feed: it is
* evidence about money, not about tipping.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class WalletMilestoneGatingTest {

@get:Rule
val instantExecutorRule = InstantTaskExecutorRule()

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

private val hasEverTipped = MutableStateFlow<Boolean?>(null)

private val chatCoordinator: ChatCoordinator = mockk(relaxed = true) {
every { hasEverTipped() } returns hasEverTipped
}
private val feedCoordinator: ActivityFeedCoordinator = mockk(relaxed = true) {
every { hasEverReceivedMoney() } returns flowOf(true)
}
private val tokenCoordinator: TokenCoordinator = mockk(relaxed = true) {
every { hasAnyBalance } returns flowOf(true)
}

private lateinit var dispatchers: TestDispatchers

private fun createViewModel() = WalletViewModel(
userManager = mockk<UserManager>(relaxed = true),
userFlags = mockk<UserFlagsCoordinator>(relaxed = true),
dispatchers = dispatchers,
purchaseMethodController = mockk<PurchaseMethodController>(relaxed = true),
analytics = StubFlipcashAnalytics(),
chatCoordinator = chatCoordinator,
feedCoordinator = feedCoordinator,
tokenCoordinator = tokenCoordinator,
)

@Test
fun `milestones are withheld while the tip milestone is unknown`() =
runTest(mainCoroutineRule.dispatcher) {
dispatchers = TestDispatchers(testScheduler)

val vm = createViewModel()
advanceUntilIdle()

assertNull(
vm.stateFlow.value.onboardingItems,
"an un-reconciled chat cache reports every account as never having tipped",
)
assertTrue(
vm.stateFlow.value.isAwaitingActivity,
"the tab must keep loading rather than draw a milestone it cannot yet answer",
)
}

@Test
fun `milestones are published once the tip milestone resolves`() =
runTest(mainCoroutineRule.dispatcher) {
dispatchers = TestDispatchers(testScheduler)

val vm = createViewModel()
advanceUntilIdle()
hasEverTipped.value = true
advanceUntilIdle()

val items = vm.stateFlow.value.onboardingItems
assertNotNull(items)
assertEquals(
listOf(true, true),
items.map { it.isCompleted },
"both milestones read complete: the account holds a balance and has tipped",
)
assertTrue(items.any { it is TutorialItem.ScanTipCard })
}
}
1 change: 1 addition & 0 deletions apps/flipcash/shared/authentication/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ dependencies {
implementation(libs.androidx.datastore)

implementation(project(":apps:flipcash:shared:appsettings"))
implementation(project(":apps:flipcash:shared:chat"))
implementation(project(":apps:flipcash:shared:contacts"))
implementation(project(":apps:flipcash:shared:persistence:provider"))
implementation(project(":apps:flipcash:shared:push"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import com.flipcash.app.auth.internal.credentials.PassphraseCredentialManager
import com.flipcash.app.contacts.ContactCoordinator
import com.flipcash.app.featureflags.FeatureFlagController
import com.flipcash.app.persistence.PersistenceProvider
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.app.push.PushTokenProvider
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.userflags.UserFlagsCoordinator
Expand Down Expand Up @@ -54,6 +55,7 @@ class AuthManager @Inject constructor(
private val userFlags: UserFlagsCoordinator,
private val profileCoordinator: ProfileCoordinator,
private val contactCoordinator: ContactCoordinator,
private val chatCoordinator: ChatCoordinator,
private val networkObserver: NetworkConnectivityListener,
private val dispatchers: DispatcherProvider,
// private val analytics: AnalyticsService,
Expand Down Expand Up @@ -315,6 +317,11 @@ class AuthManager @Inject constructor(
//todo: add account deletion
// Wipe server contact set before logout while the session can still authenticate.
contactCoordinator.clearServerContactSet()
// Deletion is the only path that erases the local chat cache. Plain logout and account
// switching leave it — the database is per-account, so it never has to be emptied to keep
// accounts apart, and keeping it lets a re-login reconcile rather than refetch. That makes
// this the one caller responsible for the data actually going away.
chatCoordinator.clearCache()
return logout()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import com.flipcash.app.persistence.PersistenceProvider
import com.flipcash.app.push.PushTokenProvider
import com.flipcash.app.tokens.TokenCoordinator
import com.flipcash.app.userflags.UserFlagsCoordinator
import com.flipcash.shared.chat.ChatCoordinator
import com.flipcash.shared.profile.ProfileCoordinator
import com.flipcash.services.controllers.AccountController
import com.flipcash.services.controllers.ProfileController
Expand Down Expand Up @@ -65,6 +66,7 @@ class AuthManagerTest {
private val userFlags: UserFlagsCoordinator = mockk(relaxed = true)
private val profileCoordinator: ProfileCoordinator = mockk(relaxed = true)
private val contactCoordinator: ContactCoordinator = mockk(relaxed = true)
private val chatCoordinator: ChatCoordinator = mockk(relaxed = true)
private val userManagerState = MutableStateFlow(UserManager.State())

private val networkConnectivityListener = mockk<NetworkConnectivityListener>(relaxed = true)
Expand Down Expand Up @@ -108,6 +110,7 @@ class AuthManagerTest {
userFlags = userFlags,
profileCoordinator = profileCoordinator,
contactCoordinator = contactCoordinator,
chatCoordinator = chatCoordinator,
dispatchers = dispatchers,
networkObserver = networkConnectivityListener,
)
Expand Down Expand Up @@ -164,6 +167,27 @@ class AuthManagerTest {
verify { userFlags.clearAll() }
}

@Test
fun `logout leaves the chat cache in place`() = runTest {
coEvery { credentialManager.logout() } returns Result.success(Unit)

authManager.logout()

// The Room file is named from the account entropy, so signing out never has to erase chat
// history to keep accounts apart — and keeping it lets a re-login reconcile what it already
// has instead of rebuilding from one message per chat.
coVerify(exactly = 0) { chatCoordinator.clearCache() }
}

@Test
fun `deleteAndLogout erases the chat cache`() = runTest {
coEvery { credentialManager.logout() } returns Result.success(Unit)

authManager.deleteAndLogout()

coVerify(exactly = 1) { chatCoordinator.clearCache() }
}

@Test
fun `non-soft login sets softLoginDisabled flag`() = runTest {
val entropy = "dGVzdGVudHJvcHkxMjM0NQ=="
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,19 @@ interface MessagingOperations {
/** Observes all messages in [chatId] as a flat list. */
fun observeMessages(chatId: ChatId): Flow<List<ChatMessage>>

/** True once the user has ever sent a tip (a Cash message with verb TIPPED) — onboarding milestone. */
fun hasEverTipped(): Flow<Boolean>
/**
* Whether the user has ever sent a tip — a Cash message with verb TIPPED — or `null` while the
* answer is not yet trustworthy.
*
* The read is of a local cache, so an absent TIPPED message means "never tipped" only once that
* cache is known to be complete; before then it is indistinguishable from history that has not
* arrived. Onboarding is the caller, and it draws a tutorial off the answer, so it needs the
* third state rather than a `false` it has to guess about. Resolves to `true`/`false` once
* [ChatHydrationState] leaves [ChatHydrationState.Unknown] — including on
* [ChatHydrationState.Unavailable], so an unreachable server ends the wait instead of extending
* it forever.
*/
fun hasEverTipped(): Flow<Boolean?>

/** Observes messages in [chatId] via Paging 3, with remote-mediated page loads. */
fun observeMessagesPaged(chatId: ChatId): Flow<PagingData<ChatMessage>>
Expand Down Expand Up @@ -166,8 +177,27 @@ interface ChatCoordinator : FeedOperations, EventStreamOperations, DmChatResolve
/** Full observable snapshot of chat state (feed, typing, reactions, active chat). */
val state: StateFlow<ChatState>

/** Tears down all connections, clears persisted data, and resets in-memory state. */
suspend fun reset()
/**
* Closes connections, cancels jobs, and drops in-memory state.
*
* Deliberately leaves the persisted cache alone. The Room database is per-account
* (`FlipcashDatabase.init` names the file from the account entropy), so signing out does not
* have to erase anything to keep the next account's data separate — logging in swaps to a
* different file. Keeping the cache lets a re-login reconcile what it already has via the
* feed sync's catch-up instead of rebuilding from nothing, which is what the "send a tip"
* onboarding milestone was silently losing.
*
* @see clearCache for the one caller that does want the data gone.
*/
suspend fun teardown()

/**
* Erases this account's persisted chat history — metadata, messages, and members.
*
* Account deletion only. Ordinary logout and account switching go through [teardown] and
* keep the cache.
*/
suspend fun clearCache()
}

class NoDmChatInitializedException(e164: String) : Exception("No DM chat for $e164")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ data class ChatState(
val typingIndicators: Map<ChatId, Set<ActiveTypist>> = emptyMap(),
val reactionOverlays: Map<ChatId, Map<Long, ReactionSummary>> = emptyMap(),
val feedSyncState: FeedSyncState = FeedSyncState.Idle,
val historyHydration: ChatHydrationState = ChatHydrationState.Unknown,
val activeChat: ChatId? = null,
)

Expand All @@ -30,3 +31,24 @@ enum class FeedSyncState {
Synced,
Error,
}

/**
* How far the *message* cache has got in reconciling itself with the server for the signed-in user.
*
* Distinct from [FeedSyncState], which reports only the conversation-list fetch. That fetch marks
* itself [FeedSyncState.Synced] as soon as the metadata lands, before the per-chat history backfill
* it schedules has run — so it cannot answer "is this account's chat history here yet". Callers that
* read chat history as evidence need that second question: the wallet's "send a tip" milestone looks
* for an outgoing TIPPED message, and an absent one only means the user has never tipped once the
* cache is known to be complete.
*/
enum class ChatHydrationState {
/** No sync has finished a full pass this session — an absent message proves nothing. */
Unknown,

/** A sync succeeded and every catch-up it scheduled has run: an absent message really is absent. */
Hydrated,

/** A sync completed without success. Callers should stop waiting; the next sync will retry. */
Unavailable,
}
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,13 @@ class RealChatCoordinator @Inject constructor(
is FeedSyncDelegate.Event.LoadMessages ->
messagingDelegate.loadMessages(event.chatId)
is FeedSyncDelegate.Event.DeltaSyncNeeded ->
eventStreamDelegate.performDeltaSync(event.chatId)
eventStreamDelegate.performDeltaSync(event.chatId, event.afterSequence)
// Arrives after every catch-up item above it, because this is one sequential
// collector over a FIFO channel. Anything reading chat history as evidence —
// the wallet's "send a tip" milestone — waits for this rather than for the
// feed sync, which reports itself synced before the backfill is scheduled.
FeedSyncDelegate.Event.CatchUpComplete ->
feedDelegate.markHistoryHydrated()
}
}.launchIn(scope)

Expand Down Expand Up @@ -197,17 +203,21 @@ class RealChatCoordinator @Inject constructor(

// region ChatCoordinator

override suspend fun reset() {
override suspend fun teardown() {
eventStreamDelegate.stopHeartbeat()
eventStreamDelegate.close()
feedDelegate.cancelJobs()
networkObserverJob?.cancel()
stateHolder.reset()
eventStreamDelegate.clearAll()
cluster.value = null
messagingDelegate.clear()
supervisorJob.cancel()
trace(tag = TAG, message = "reset complete", type = TraceType.Process)
trace(tag = TAG, message = "teardown complete", type = TraceType.Process)
}

override suspend fun clearCache() {
messagingDelegate.clear()
trace(tag = TAG, message = "cache cleared", type = TraceType.Process)
}

// endregion
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,17 @@ class EventStreamDelegate @Inject constructor(
heartbeatJob = null
}

internal suspend fun performDeltaSync(chatId: ChatId) {
val afterSequence = metadataDataSource.getLatestEventSequence(chatId)
/**
* Backfills a chat from [afterSequence] onward.
*
* Callers that have already written the server's sequence to `chat_metadata` before asking for
* a delta must pass the sequence the cache held *beforehand* — reading it here would return
* the value they just wrote and ask the server for everything after its own latest event,
* which is always nothing. The live gap-fill path has no such write in front of it and omits
* the argument.
*/
internal suspend fun performDeltaSync(chatId: ChatId, afterSequence: Long? = null) {
val afterSequence = afterSequence ?: metadataDataSource.getLatestEventSequence(chatId)
trace(tag = TAG, message = "Delta sync for $chatId from sequence $afterSequence", type = TraceType.Process)

try {
Expand Down
Loading
Loading