From bc6bae51ae2a4b4af1618932882296eaebad0ca0 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 29 May 2026 16:56:07 -0400 Subject: [PATCH] refactor(contacts): extract ContactDataSource from direct DAO usage Move ContactCoordinator off FlipcashDatabase.getInstance()?.contactDao() calls and onto an injectable ContactDataSource in persistence:sources. This centralises contact persistence access and makes it reusable by other modules (e.g. notifications). Signed-off-by: Brandon McAnsh --- .../flipcash/shared/contacts/build.gradle.kts | 1 + .../app/contacts/ContactCoordinator.kt | 71 +++++----------- .../app/persistence/dao/ContactDao.kt | 3 + .../persistence/sources/build.gradle.kts | 1 + .../persistence/sources/ContactDataSource.kt | 83 +++++++++++++++++++ 5 files changed, 111 insertions(+), 48 deletions(-) create mode 100644 apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt diff --git a/apps/flipcash/shared/contacts/build.gradle.kts b/apps/flipcash/shared/contacts/build.gradle.kts index cca83f5fc..d2168c68a 100644 --- a/apps/flipcash/shared/contacts/build.gradle.kts +++ b/apps/flipcash/shared/contacts/build.gradle.kts @@ -14,6 +14,7 @@ dependencies { implementation(project(":services:flipcash")) implementation(project(":services:opencode")) implementation(project(":apps:flipcash:shared:persistence:db")) + implementation(project(":apps:flipcash:shared:persistence:sources")) implementation(project(":apps:flipcash:shared:phone")) implementation(project(":apps:flipcash:shared:featureflags")) implementation(project(":libs:encryption:keys")) 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 e377d5b7b..c16bdc86c 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 @@ -11,9 +11,8 @@ import com.flipcash.app.contacts.device.PickedContactData import com.flipcash.app.contacts.device.ScopeAwareContactReader import com.flipcash.app.phone.PhoneUtils import com.flipcash.app.contacts.sync.ContactChecksum -import com.flipcash.app.persistence.FlipcashDatabase import com.flipcash.app.persistence.entities.ContactMappingEntity -import com.flipcash.app.persistence.entities.ContactSyncStateEntity +import com.flipcash.app.persistence.sources.ContactDataSource import com.flipcash.services.controllers.ContactListController import com.flipcash.services.controllers.ResolverController import com.flipcash.services.models.CheckSyncError @@ -54,6 +53,7 @@ class ContactCoordinator @Inject constructor( private val networkObserver: NetworkConnectivityListener, private val contactReader: ScopeAwareContactReader, private val phoneUtils: PhoneUtils, + private val contactDataSource: ContactDataSource, ) : SessionListener, DefaultLifecycleObserver { companion object { @@ -134,8 +134,7 @@ class ContactCoordinator @Inject constructor( suspend fun removeContact(e164: String) { contactReader.removeSelectedContact(e164) - val db = FlipcashDatabase.getInstance() ?: return - db.contactDao().deleteMappings(listOf(e164)) + contactDataSource.deleteMappings(listOf(e164)) _state.update { state -> state.copy( contacts = state.contacts - e164, @@ -153,8 +152,7 @@ class ContactCoordinator @Inject constructor( _state.value = ContactState() cluster.value = null contactReader.reset() - val db = FlipcashDatabase.getInstance() ?: return - db.contactDao().clearAll() + contactDataSource.clear() trace(tag = TAG, message = "reset complete", type = TraceType.Process) } @@ -166,8 +164,7 @@ class ContactCoordinator @Inject constructor( * the next foreground retries. */ suspend fun clearServerContactSetIfRevoked() { - val db = FlipcashDatabase.getInstance() ?: return - val syncState = db.contactDao().getSyncState() ?: return + val syncState = contactDataSource.getSyncState() ?: return if (syncState.checksumBytes.all { it == 0.toByte() }) return if (!contactReader.isPermissionRevoked()) return @@ -175,7 +172,7 @@ class ContactCoordinator @Inject constructor( clearServerContactSet() _state.value = ContactState() contactReader.reset() - db.contactDao().clearAll() + contactDataSource.clear() trace(tag = TAG, message = "Cleared server contact set after permission revoke", type = TraceType.Process) } @@ -201,9 +198,8 @@ class ContactCoordinator @Inject constructor( // region Internal private suspend fun hydrateFromPersistence() { - val db = FlipcashDatabase.getInstance() ?: return - val syncState = db.contactDao().getSyncState() - val mappings = db.contactDao().getAllMappings() + val syncState = contactDataSource.getSyncState() + val mappings = contactDataSource.get() val hasEverSynced = syncState != null || mappings.isNotEmpty() if (mappings.isEmpty()) { @@ -253,12 +249,7 @@ class ContactCoordinator @Inject constructor( val newChecksum = ContactChecksum.compute(deviceContacts.keys) // 3. Diff against persisted mappings - val db = FlipcashDatabase.getInstance() ?: run { - _state.update { it.copy(syncState = SyncState.Error) } - return Result.failure(IllegalStateException("Database unavailable")) - } - val dao = db.contactDao() - val existingMappings = dao.getAllMappings() + val existingMappings = contactDataSource.get() val existingE164s = existingMappings.map { it.e164 }.toSet() val newE164s = deviceContacts.keys @@ -275,9 +266,9 @@ class ContactCoordinator @Inject constructor( displayNumber = phoneUtils.formatNumber(contact.e164), ) } - dao.upsertMappings(allEntities) + contactDataSource.upsert(allEntities) if (removes.isNotEmpty()) { - dao.deleteMappings(removes.toList()) + contactDataSource.deleteMappings(removes.toList()) } // Update in-memory contacts with displayNumber, merging into existing state @@ -288,7 +279,7 @@ class ContactCoordinator @Inject constructor( _state.update { it.copy(contacts = it.contacts + enrichedContacts) } // 5. CheckSync with server - val syncState = dao.getSyncState() + val syncState = contactDataSource.getSyncState() val oldChecksum = syncState?.let { Checksum(it.checksumBytes.toList()) } val checkSyncResult = contactListController.checkSync(newChecksum) @@ -297,7 +288,7 @@ class ContactCoordinator @Inject constructor( onSuccess = { serverChecksum -> // Checksums match — skip upload trace(tag = TAG, message = "Contacts in sync with server", type = TraceType.Process) - persistSyncState(dao, newChecksum) + contactDataSource.persistSyncState(newChecksum) }, onFailure = { error -> when (error) { @@ -313,30 +304,30 @@ class ContactCoordinator @Inject constructor( deltaResult.fold( onSuccess = { trace(tag = TAG, message = "Delta upload successful", type = TraceType.Process) - persistSyncState(dao, newChecksum) + contactDataSource.persistSyncState(newChecksum) }, onFailure = { deltaError -> if (deltaError is DeltaUploadError.ChecksumDrift || deltaError is DeltaUploadError.ChecksumMismatch) { - performFullUpload(newE164s, newChecksum, dao) + performFullUpload(newE164s, newChecksum) } else { trace(tag = TAG, message = "Delta upload failed: ${deltaError.message}", type = TraceType.Error) } } ) } else { - performFullUpload(newE164s, newChecksum, dao) + performFullUpload(newE164s, newChecksum) } } else -> { // First sync or other error — full upload - performFullUpload(newE164s, newChecksum, dao) + performFullUpload(newE164s, newChecksum) } } } ) // 6. GetFlipcashContacts - fetchFlipcashContacts(newChecksum, dao) + fetchFlipcashContacts(newChecksum) _state.update { it.copy(syncState = SyncState.Synced, hasEverSynced = true) } trace(tag = TAG, message = "Contact sync complete", type = TraceType.Process) @@ -352,7 +343,6 @@ class ContactCoordinator @Inject constructor( private suspend fun performFullUpload( e164s: Set, checksum: Checksum, - dao: com.flipcash.app.persistence.dao.ContactDao, ) { val phones = e164s.map { ContactMethod.Phone(it) } val chunked = phones.chunked(500) @@ -365,7 +355,7 @@ class ContactCoordinator @Inject constructor( result.fold( onSuccess = { trace(tag = TAG, message = "Full upload successful (${e164s.size} contacts)", type = TraceType.Process) - persistSyncState(dao, checksum) + contactDataSource.persistSyncState(checksum) }, onFailure = { error -> trace(tag = TAG, message = "Full upload failed: ${error.message}", type = TraceType.Error) @@ -373,37 +363,22 @@ class ContactCoordinator @Inject constructor( ) } - private suspend fun persistSyncState( - dao: com.flipcash.app.persistence.dao.ContactDao, - checksum: Checksum, - ) { - dao.upsertSyncState( - ContactSyncStateEntity( - checksumBytes = checksum.byteArray, - lastSyncTimestamp = System.currentTimeMillis(), - ) - ) - } - - private suspend fun fetchFlipcashContacts( - checksum: Checksum, - dao: com.flipcash.app.persistence.dao.ContactDao, - ) { + private suspend fun fetchFlipcashContacts(checksum: Checksum) { try { val result = contactListController.getFlipcashContacts(checksum) .firstOrNull() result?.onSuccess { phones -> val flipcashE164s = phones.map { it.phoneNumber }.toSet() - dao.clearFlipcashStatus() + contactDataSource.clearFlipcashStatus() if (flipcashE164s.isNotEmpty()) { - dao.markAsFlipcash(flipcashE164s.toList()) + contactDataSource.markAsFlipcash(flipcashE164s.toList()) } _state.update { it.copy(flipcashE164s = flipcashE164s) } trace(tag = TAG, message = "Found ${flipcashE164s.size} contacts on Flipcash", type = TraceType.Process) }?.onFailure { error -> if (error is GetContactsError.NotFound) { - dao.clearFlipcashStatus() + contactDataSource.clearFlipcashStatus() _state.update { it.copy(flipcashE164s = emptySet()) } trace(tag = TAG, message = "No contacts on Flipcash yet", type = TraceType.Process) } else { 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 95d77236d..8a7829b3a 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 @@ -42,6 +42,9 @@ interface ContactDao { @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun upsertMappings(mappings: List) + @Query("SELECT displayName FROM contact_mapping WHERE e164 = :e164 LIMIT 1") + suspend fun getDisplayName(e164: String): String? + @Query("DELETE FROM contact_mapping WHERE e164 IN (:e164s)") suspend fun deleteMappings(e164s: List) diff --git a/apps/flipcash/shared/persistence/sources/build.gradle.kts b/apps/flipcash/shared/persistence/sources/build.gradle.kts index 0f1b4a713..bf8a6015e 100644 --- a/apps/flipcash/shared/persistence/sources/build.gradle.kts +++ b/apps/flipcash/shared/persistence/sources/build.gradle.kts @@ -24,6 +24,7 @@ dependencies { implementation(project(":apps:flipcash:shared:persistence:db")) implementation(project(":libs:encryption:base58")) + implementation(project(":libs:encryption:keys")) implementation(project(":libs:encryption:utils")) implementation(project(":services:flipcash")) 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 new file mode 100644 index 000000000..b1298dc07 --- /dev/null +++ b/apps/flipcash/shared/persistence/sources/src/main/kotlin/com/flipcash/app/persistence/sources/ContactDataSource.kt @@ -0,0 +1,83 @@ +package com.flipcash.app.persistence.sources + +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ContactMappingEntity +import com.flipcash.app.persistence.entities.ContactSyncStateEntity +import com.flipcash.services.persistence.SingularDataSource +import com.getcode.solana.keys.Checksum +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.map +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ContactDataSource @Inject constructor( +) : SingularDataSource> { + + private val db: FlipcashDatabase? + get() = FlipcashDatabase.getInstance() + + // region SingularDataSource + + override suspend fun getById(id: String): ContactMappingEntity? = + get().firstOrNull { it.e164 == id } + + override suspend fun get(): List = + db?.contactDao()?.getAllMappings() ?: emptyList() + + override suspend fun upsert(value: List) { + db?.contactDao()?.upsertMappings(value) + } + + override suspend fun query(whereClause: String): List = emptyList() + + override suspend fun getMostRecent(): ContactMappingEntity? = null + + override suspend fun clear() { + db?.contactDao()?.clearAll() + } + + override fun observe(): Flow> = + db?.contactDao()?.observeAllMappings() ?: emptyFlow() + + override fun observe(id: String): Flow = + observe().map { mappings -> mappings.firstOrNull { it.e164 == id } } + + // endregion + + // region Sync state + + suspend fun getSyncState(): ContactSyncStateEntity? = + db?.contactDao()?.getSyncState() + + suspend fun persistSyncState(checksum: Checksum) { + db?.contactDao()?.upsertSyncState( + ContactSyncStateEntity( + checksumBytes = checksum.byteArray, + lastSyncTimestamp = System.currentTimeMillis(), + ) + ) + } + + // endregion + + // region Contact-specific operations + + suspend fun deleteMappings(e164s: List) { + db?.contactDao()?.deleteMappings(e164s) + } + + suspend fun clearFlipcashStatus() { + db?.contactDao()?.clearFlipcashStatus() + } + + suspend fun markAsFlipcash(e164s: List) { + db?.contactDao()?.markAsFlipcash(e164s) + } + + suspend fun getDisplayName(e164: String): String? = + db?.contactDao()?.getDisplayName(e164) + + // endregion +}