From 37d5361d91ccc1237d65ee985f21fbbd57f69f7b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 25 Jun 2026 15:54:06 -0400 Subject: [PATCH 1/3] fix(chat): prevent delta sync from regressing lastActivity A partial-page delta sync (e.g. when latestEventSequence is 0 and the server returns only the first 100 of 125 messages) would overwrite lastActivity with the timestamp of the oldest page's latest message, causing the chat to drop in feed sort order. Now the delta sync only advances lastActivity, never regresses it. --- .../shared/chat/internal/delegates/EventStreamDelegate.kt | 8 +++++++- .../com/flipcash/app/persistence/dao/ChatMetadataDao.kt | 3 +++ .../app/persistence/sources/ChatMetadataDataSource.kt | 3 +++ 3 files changed, 13 insertions(+), 1 deletion(-) 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 ff03e2331..f50aee5a1 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 @@ -166,7 +166,13 @@ class EventStreamDelegate @Inject constructor( val latest = delta.messages.maxByOrNull { it.messageId } latest?.let { msg -> metadataDataSource.updateLastMessageId(chatId, msg.messageId) - metadataDataSource.updateLastActivity(chatId, msg.timestamp.toEpochMilliseconds()) + // Only advance lastActivity — a partial page from a + // delta sync must not regress it to an older timestamp. + val existing = metadataDataSource.getLastActivity(chatId) + val incoming = msg.timestamp.toEpochMilliseconds() + if (existing == null || incoming > existing) { + metadataDataSource.updateLastActivity(chatId, incoming) + } } } if (delta.latestSequence > afterSequence) { 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 38b3ff909..fb1512e30 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 @@ -22,6 +22,9 @@ interface ChatMetadataDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsert(entities: List) + @Query("SELECT last_activity_epoch_ms FROM chat_metadata WHERE chat_id_hex = :chatIdHex") + suspend fun getLastActivity(chatIdHex: String): Long? + @Query("UPDATE chat_metadata SET last_activity_epoch_ms = :epochMs WHERE chat_id_hex = :chatIdHex") suspend fun updateLastActivity(chatIdHex: String, epochMs: Long) 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 a8f289abd..5f24110d7 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 @@ -32,6 +32,9 @@ class ChatMetadataDataSource @Inject constructor( db?.chatMetadataDao()?.upsert(metadatas.map { mapper.toEntity(it) }) } + suspend fun getLastActivity(chatId: ChatId): Long? = + db?.chatMetadataDao()?.getLastActivity(mapper.chatIdHex(chatId)) + suspend fun updateLastActivity(chatId: ChatId, epochMs: Long) { db?.chatMetadataDao()?.updateLastActivity(mapper.chatIdHex(chatId), epochMs) } From cba011b4206ddb86eed7899181f948536ef31235 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 25 Jun 2026 15:59:16 -0400 Subject: [PATCH 2/3] chore(send): update cash message preview to use stringRes Signed-off-by: Brandon McAnsh --- .../core/src/main/res/values/strings.xml | 4 +++ .../directsend/internal/SendFlowViewModel.kt | 27 ++++++++++++++----- 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 8dd4cc9a9..92787dc3e 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -797,4 +797,8 @@ Are You Sure? Anyone you sent the link to won\'t be able to collect the cash + %1$s of %2$s + You sent %1$s + You received %1$s + \ No newline at end of file diff --git a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt index 2c705ebbb..d3f3a4a73 100644 --- a/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt +++ b/apps/flipcash/features/direct-send/src/main/kotlin/com/flipcash/app/directsend/internal/SendFlowViewModel.kt @@ -1,4 +1,5 @@ package com.flipcash.app.directsend.internal + import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.snapshotFlow @@ -99,7 +100,8 @@ internal class SendFlowViewModel @Inject constructor( val phoneNumberSendEnabled = phoneNumberSendFlag || userState.flags?.enablePhoneNumberSend == true val hasContacts = contactState.contacts.isNotEmpty() - val needsContacts = phoneNumberSendEnabled && !hasContacts && !contactState.hasEverSynced + val needsContacts = + phoneNumberSendEnabled && !hasContacts && !contactState.hasEverSynced val steps = buildList { if (!hasLinkedPhone) add(SendStep.PhoneGate) @@ -263,7 +265,8 @@ internal class SendFlowViewModel @Inject constructor( val contact = if (deviceContact != null) { if (searchString.isNotBlank() && !deviceContact.displayName.contains(searchString, ignoreCase = true) && - !deviceContact.e164.contains(searchString, ignoreCase = true)) { + !deviceContact.e164.contains(searchString, ignoreCase = true) + ) { return@mapNotNull null } recentsE164s += deviceContact.e164 @@ -287,7 +290,8 @@ internal class SendFlowViewModel @Inject constructor( displayNumber = formattedPhone, ) if (searchString.isNotBlank() && - !unknown.displayName.contains(searchString, ignoreCase = true)) { + !unknown.displayName.contains(searchString, ignoreCase = true) + ) { return@mapNotNull null } if (unknown.e164.isNotEmpty()) { @@ -346,11 +350,17 @@ internal class SendFlowViewModel @Inject constructor( is MessageContent.Text -> content.text.takeIf { it.isNotEmpty() } is MessageContent.Cash -> { val formatted = content.amount.formatted() - val name = content.tokenName.ifBlank { - tokensByMint[content.mint]?.name.orEmpty() + val name = content.tokenName.ifBlank { tokensByMint[content.mint]?.name.orEmpty() } + val label = if (name.isNotBlank()) { + resources.getString(R.string.label_chat_preview_cash_suffix, formatted, name) + } else { + formatted + } + if (sentBySelf) { + resources.getString(R.string.label_chat_preview_sentCash, label) + } else { + resources.getString(R.string.label_chat_preview_receivedCash, label) } - val label = if (name.isNotBlank()) "$formatted of $name" else formatted - if (sentBySelf) "You sent $label" else "You received $label" } // TODO: @@ -368,9 +378,11 @@ internal class SendFlowViewModel @Inject constructor( is Event.StepsUpdated -> { state -> state.copy(steps = event.steps, isPickerMode = event.isPickerMode) } + is Event.OnStepChanged -> { state -> state.copy(currentStep = event.step) } + is Event.ContactsGranted -> { state -> state } is Event.ContactsPicked -> { state -> state } is Event.ContactSyncStateUpdated -> { state -> @@ -382,6 +394,7 @@ internal class SendFlowViewModel @Inject constructor( ) ) } + is Event.ContactRemoved -> { state -> state } is Event.ContactSyncComplete -> { state -> state } is Event.OnItemsPopulated -> { state -> state.copy(listItems = event.items) } From 6d526986f445a28125105dbb9e6c017ef44cdc1f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Thu, 25 Jun 2026 15:59:31 -0400 Subject: [PATCH 3/3] chore: fix coinbase onramp controller tests Signed-off-by: Brandon McAnsh --- .../com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt index 747e0acf7..966e91d3b 100644 --- a/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt +++ b/apps/flipcash/shared/onramp/coinbase/src/test/kotlin/com/flipcash/app/onramp/CoinbaseOnRampControllerTest.kt @@ -3,6 +3,7 @@ package com.flipcash.app.onramp import com.coinbase.onramp.api.CoinbaseApi import com.coinbase.onramp.data.OnRampApiConfig import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.userflags.UserFlagsCoordinator import com.flipcash.services.models.UserProfile import com.flipcash.services.user.UserManager import com.getcode.opencode.exchange.Exchange @@ -47,6 +48,7 @@ class CoinbaseOnRampControllerTest { private val featureFlags = mockk(relaxed = true) private val googlePayReadiness = mockk(relaxed = true) private val webViewChannelDetector = mockk(relaxed = true) + private val userFlags = mockk(relaxed = true) private val onRampApiEndpoint = OnRampApiConfig( scheme = "https", @@ -86,6 +88,7 @@ class CoinbaseOnRampControllerTest { transactionController = mockk(relaxed = true), googlePayReadiness = googlePayReadiness, webViewChannelDetector = webViewChannelDetector, + userFlags = userFlags, ) }