From 6b3d3fa27f5e197115d179e4870f0d0ad179d805 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 29 May 2026 21:04:44 -0400 Subject: [PATCH] feat(contacts): show one-time Flipcash contacts discovery prompt Persist a `hasDiscoveredFlipcashContacts` flag on `ContactSyncStateEntity` (set to `true` the first time `fetchFlipcashContacts()` returns a non-empty set) and surface it as a once-ever `BottomBarManager.showInfo()` prompt in `SendFlowViewModel`. When dismissed, `consumeContactsDiscovery()` clears the flag in both the DB and in-memory state so the prompt never reappears. Also fix `clearAll()` to delete all mappings on logout (prevents stale `hasEverSynced` on re-login) and sequence the permission-revocation check before sync on foreground resume to eliminate a race. Signed-off-by: Brandon McAnsh --- .../directsend/internal/SendFlowViewModel.kt | 24 +- .../app/contacts/ContactCoordinator.kt | 29 +- .../18.json | 483 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 3 +- .../app/persistence/dao/ContactDao.kt | 8 +- .../entities/ContactSyncStateEntity.kt | 7 +- .../app/persistence/dao/ContactDaoTest.kt | 7 +- .../persistence/sources/ContactDataSource.kt | 11 + 8 files changed, 558 insertions(+), 14 deletions(-) create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/18.json 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 caff1ddc7..7865bbf3f 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 @@ -15,6 +15,7 @@ import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.permissions.PickedContact import com.flipcash.features.directsend.R import com.flipcash.services.user.UserManager +import com.getcode.manager.BottomBarManager import com.getcode.util.resources.ResourceHelper import com.getcode.view.BaseViewModel import com.getcode.view.LoadingSuccessState @@ -23,10 +24,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch import javax.inject.Inject import kotlin.time.Duration.Companion.seconds @@ -159,7 +162,26 @@ internal class SendFlowViewModel @Inject constructor( .onEach { event -> contactCoordinator.removeContact(event.e164) } .launchIn(viewModelScope) - // SendInvite is observed by the UI layer (ContactListScreen) for navigation + contactCoordinator.state + .filter { it.hasDiscoveredFlipcashContacts && it.flipcashE164s.isNotEmpty() } + .take(1) + .onEach { contactState -> + val count = contactState.flipcashE164s.size + BottomBarManager.showInfo( + title = resources.getQuantityString( + R.plurals.prompt_title_contactsAlreadyOnFlipcash, + count, + count.toString(), + ), + message = resources.getString(R.string.prompt_description_contactsAlreadyOnFlipcash), + onDismiss = { + viewModelScope.launch { + contactCoordinator.consumeContactsDiscovery() + } + } + ) + } + .launchIn(viewModelScope) } private fun generateListItems( diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt index e86900779..c1c488c81 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -95,6 +95,7 @@ class ContactCoordinator @Inject constructor( val flipcashE164s: Set = emptySet(), val syncState: SyncState = SyncState.Idle, val hasEverSynced: Boolean = false, + val hasDiscoveredFlipcashContacts: Boolean = false, ) enum class SyncState { Idle, Syncing, Synced, Error } @@ -136,9 +137,12 @@ class ContactCoordinator @Inject constructor( override fun onStart(owner: LifecycleOwner) { if (cluster.value != null) { - scope.launch { clearServerContactSetIfRevoked() } - trace(tag = TAG, message = "Lifecycle resumed, triggering contact sync", type = TraceType.Process) - launchSync() + syncJob?.cancel() + syncJob = scope.launch { + clearServerContactSetIfRevoked() + trace(tag = TAG, message = "Lifecycle resumed, triggering contact sync", type = TraceType.Process) + performSync() + } } } @@ -213,6 +217,11 @@ class ContactCoordinator @Inject constructor( } } + suspend fun consumeContactsDiscovery() { + contactDataSource.clearFlipcashContactsDiscovered() + _state.update { it.copy(hasDiscoveredFlipcashContacts = false) } + } + suspend fun reset() { syncJob?.cancel() _state.value = ContactState() @@ -269,9 +278,10 @@ class ContactCoordinator @Inject constructor( val mappings = contactDataSource.get() val hasEverSynced = syncState != null || mappings.isNotEmpty() + val hasDiscoveredFlipcashContacts = syncState?.hasDiscoveredFlipcashContacts ?: false if (mappings.isEmpty()) { if (hasEverSynced) { - _state.update { it.copy(hasEverSynced = true) } + _state.update { it.copy(hasEverSynced = true, hasDiscoveredFlipcashContacts = hasDiscoveredFlipcashContacts) } } return } @@ -288,7 +298,12 @@ class ContactCoordinator @Inject constructor( val flipcashE164s = mappings.filter { it.isOnFlipcash }.map { it.e164 }.toSet() _state.update { - it.copy(contacts = contacts, flipcashE164s = flipcashE164s, hasEverSynced = true) + it.copy( + contacts = contacts, + flipcashE164s = flipcashE164s, + hasEverSynced = true, + hasDiscoveredFlipcashContacts = hasDiscoveredFlipcashContacts, + ) } trace(tag = TAG, message = "Hydrated ${mappings.size} contacts from persistence", type = TraceType.Process) @@ -440,6 +455,10 @@ class ContactCoordinator @Inject constructor( contactDataSource.clearFlipcashStatus() if (flipcashE164s.isNotEmpty()) { contactDataSource.markAsFlipcash(flipcashE164s.toList()) + if (!_state.value.hasDiscoveredFlipcashContacts) { + contactDataSource.markFlipcashContactsDiscovered() + _state.update { it.copy(hasDiscoveredFlipcashContacts = true) } + } } _state.update { it.copy(flipcashE164s = flipcashE164s) } trace(tag = TAG, message = "Found ${flipcashE164s.size} contacts on Flipcash", type = TraceType.Process) diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/18.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/18.json new file mode 100644 index 000000000..63ef1a30a --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/18.json @@ -0,0 +1,483 @@ +{ + "formatVersion": 1, + "database": { + "version": 18, + "identityHash": "4ddfb1c5c490690410368d8747bf515c", + "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', 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'" + } + ], + "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, `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": "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 '', 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": "''" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + } + ], + "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, '4ddfb1c5c490690410368d8747bf515c')" + ] + } +} \ 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 06dcd34e2..e8cfd71f7 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 @@ -55,8 +55,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 14, to = 15), AutoMigration(from = 15, to = 16), AutoMigration(from = 16, to = 17), + AutoMigration(from = 17, to = 18), ], - version = 17, + version = 18, ) @TypeConverters(TokenTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt index da41ca9c3..96e6bb604 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt @@ -59,9 +59,15 @@ interface ContactDao { @Query("DELETE FROM contact_mapping") suspend fun deleteAllMappings() + @Query("UPDATE contact_sync_state SET hasDiscoveredFlipcashContacts = 1 WHERE id = 0") + suspend fun markFlipcashContactsDiscovered() + + @Query("UPDATE contact_sync_state SET hasDiscoveredFlipcashContacts = 0 WHERE id = 0") + suspend fun clearFlipcashContactsDiscovered() + @Transaction suspend fun clearAll() { clearSyncState() - clearFlipcashStatus() + deleteAllMappings() } } diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactSyncStateEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactSyncStateEntity.kt index 321232bc2..c306c013b 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactSyncStateEntity.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactSyncStateEntity.kt @@ -1,5 +1,6 @@ package com.flipcash.app.persistence.entities +import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -10,6 +11,8 @@ data class ContactSyncStateEntity( val checksumBytes: ByteArray, val lastSyncTimestamp: Long, val needsFullUpload: Boolean = false, + @ColumnInfo(defaultValue = "0") + val hasDiscoveredFlipcashContacts: Boolean = false, ) { override fun equals(other: Any?): Boolean { if (this === other) return true @@ -17,7 +20,8 @@ data class ContactSyncStateEntity( return id == other.id && checksumBytes.contentEquals(other.checksumBytes) && lastSyncTimestamp == other.lastSyncTimestamp && - needsFullUpload == other.needsFullUpload + needsFullUpload == other.needsFullUpload && + hasDiscoveredFlipcashContacts == other.hasDiscoveredFlipcashContacts } override fun hashCode(): Int { @@ -25,6 +29,7 @@ data class ContactSyncStateEntity( result = 31 * result + checksumBytes.contentHashCode() result = 31 * result + lastSyncTimestamp.hashCode() result = 31 * result + needsFullUpload.hashCode() + result = 31 * result + hasDiscoveredFlipcashContacts.hashCode() return result } } diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ContactDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ContactDaoTest.kt index cf45a76c0..9db08cbcc 100644 --- a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ContactDaoTest.kt +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ContactDaoTest.kt @@ -221,7 +221,7 @@ class ContactDaoTest { // -- clearAll tests -- @Test - fun `clearAll removes sync state and resets flipcash status`() = runTest { + fun `clearAll removes sync state and all mappings`() = runTest { dao.upsertSyncState(makeSyncState()) dao.upsertMappings(listOf( makeMapping(e164 = "+15551111111", isOnFlipcash = true), @@ -230,9 +230,6 @@ class ContactDaoTest { dao.clearAll() assertNull(dao.getSyncState()) - // Mappings still exist but flipcash status is cleared - val mappings = dao.getAllMappings() - assertEquals(1, mappings.size) - assertTrue(!mappings.first().isOnFlipcash) + assertTrue(dao.getAllMappings().isEmpty()) } } diff --git a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt index b1298dc07..e28be7b85 100644 --- a/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt @@ -60,6 +60,17 @@ class ContactDataSource @Inject constructor( ) } + suspend fun hasDiscoveredFlipcashContacts(): Boolean = + getSyncState()?.hasDiscoveredFlipcashContacts ?: false + + suspend fun markFlipcashContactsDiscovered() { + db?.contactDao()?.markFlipcashContactsDiscovered() + } + + suspend fun clearFlipcashContactsDiscovered() { + db?.contactDao()?.clearFlipcashContactsDiscovered() + } + // endregion // region Contact-specific operations