Skip to content

Commit bc6bae5

Browse files
committed
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 <git@bmcreations.dev>
1 parent cb240ba commit bc6bae5

5 files changed

Lines changed: 111 additions & 48 deletions

File tree

apps/flipcash/shared/contacts/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ dependencies {
1414
implementation(project(":services:flipcash"))
1515
implementation(project(":services:opencode"))
1616
implementation(project(":apps:flipcash:shared:persistence:db"))
17+
implementation(project(":apps:flipcash:shared:persistence:sources"))
1718
implementation(project(":apps:flipcash:shared:phone"))
1819
implementation(project(":apps:flipcash:shared:featureflags"))
1920
implementation(project(":libs:encryption:keys"))

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt

Lines changed: 23 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,8 @@ import com.flipcash.app.contacts.device.PickedContactData
1111
import com.flipcash.app.contacts.device.ScopeAwareContactReader
1212
import com.flipcash.app.phone.PhoneUtils
1313
import com.flipcash.app.contacts.sync.ContactChecksum
14-
import com.flipcash.app.persistence.FlipcashDatabase
1514
import com.flipcash.app.persistence.entities.ContactMappingEntity
16-
import com.flipcash.app.persistence.entities.ContactSyncStateEntity
15+
import com.flipcash.app.persistence.sources.ContactDataSource
1716
import com.flipcash.services.controllers.ContactListController
1817
import com.flipcash.services.controllers.ResolverController
1918
import com.flipcash.services.models.CheckSyncError
@@ -54,6 +53,7 @@ class ContactCoordinator @Inject constructor(
5453
private val networkObserver: NetworkConnectivityListener,
5554
private val contactReader: ScopeAwareContactReader,
5655
private val phoneUtils: PhoneUtils,
56+
private val contactDataSource: ContactDataSource,
5757
) : SessionListener, DefaultLifecycleObserver {
5858

5959
companion object {
@@ -134,8 +134,7 @@ class ContactCoordinator @Inject constructor(
134134

135135
suspend fun removeContact(e164: String) {
136136
contactReader.removeSelectedContact(e164)
137-
val db = FlipcashDatabase.getInstance() ?: return
138-
db.contactDao().deleteMappings(listOf(e164))
137+
contactDataSource.deleteMappings(listOf(e164))
139138
_state.update { state ->
140139
state.copy(
141140
contacts = state.contacts - e164,
@@ -153,8 +152,7 @@ class ContactCoordinator @Inject constructor(
153152
_state.value = ContactState()
154153
cluster.value = null
155154
contactReader.reset()
156-
val db = FlipcashDatabase.getInstance() ?: return
157-
db.contactDao().clearAll()
155+
contactDataSource.clear()
158156
trace(tag = TAG, message = "reset complete", type = TraceType.Process)
159157
}
160158

@@ -166,16 +164,15 @@ class ContactCoordinator @Inject constructor(
166164
* the next foreground retries.
167165
*/
168166
suspend fun clearServerContactSetIfRevoked() {
169-
val db = FlipcashDatabase.getInstance() ?: return
170-
val syncState = db.contactDao().getSyncState() ?: return
167+
val syncState = contactDataSource.getSyncState() ?: return
171168
if (syncState.checksumBytes.all { it == 0.toByte() }) return
172169

173170
if (!contactReader.isPermissionRevoked()) return
174171

175172
clearServerContactSet()
176173
_state.value = ContactState()
177174
contactReader.reset()
178-
db.contactDao().clearAll()
175+
contactDataSource.clear()
179176
trace(tag = TAG, message = "Cleared server contact set after permission revoke", type = TraceType.Process)
180177
}
181178

@@ -201,9 +198,8 @@ class ContactCoordinator @Inject constructor(
201198
// region Internal
202199

203200
private suspend fun hydrateFromPersistence() {
204-
val db = FlipcashDatabase.getInstance() ?: return
205-
val syncState = db.contactDao().getSyncState()
206-
val mappings = db.contactDao().getAllMappings()
201+
val syncState = contactDataSource.getSyncState()
202+
val mappings = contactDataSource.get()
207203

208204
val hasEverSynced = syncState != null || mappings.isNotEmpty()
209205
if (mappings.isEmpty()) {
@@ -253,12 +249,7 @@ class ContactCoordinator @Inject constructor(
253249
val newChecksum = ContactChecksum.compute(deviceContacts.keys)
254250

255251
// 3. Diff against persisted mappings
256-
val db = FlipcashDatabase.getInstance() ?: run {
257-
_state.update { it.copy(syncState = SyncState.Error) }
258-
return Result.failure(IllegalStateException("Database unavailable"))
259-
}
260-
val dao = db.contactDao()
261-
val existingMappings = dao.getAllMappings()
252+
val existingMappings = contactDataSource.get()
262253
val existingE164s = existingMappings.map { it.e164 }.toSet()
263254
val newE164s = deviceContacts.keys
264255

@@ -275,9 +266,9 @@ class ContactCoordinator @Inject constructor(
275266
displayNumber = phoneUtils.formatNumber(contact.e164),
276267
)
277268
}
278-
dao.upsertMappings(allEntities)
269+
contactDataSource.upsert(allEntities)
279270
if (removes.isNotEmpty()) {
280-
dao.deleteMappings(removes.toList())
271+
contactDataSource.deleteMappings(removes.toList())
281272
}
282273

283274
// Update in-memory contacts with displayNumber, merging into existing state
@@ -288,7 +279,7 @@ class ContactCoordinator @Inject constructor(
288279
_state.update { it.copy(contacts = it.contacts + enrichedContacts) }
289280

290281
// 5. CheckSync with server
291-
val syncState = dao.getSyncState()
282+
val syncState = contactDataSource.getSyncState()
292283
val oldChecksum = syncState?.let { Checksum(it.checksumBytes.toList()) }
293284

294285
val checkSyncResult = contactListController.checkSync(newChecksum)
@@ -297,7 +288,7 @@ class ContactCoordinator @Inject constructor(
297288
onSuccess = { serverChecksum ->
298289
// Checksums match — skip upload
299290
trace(tag = TAG, message = "Contacts in sync with server", type = TraceType.Process)
300-
persistSyncState(dao, newChecksum)
291+
contactDataSource.persistSyncState(newChecksum)
301292
},
302293
onFailure = { error ->
303294
when (error) {
@@ -313,30 +304,30 @@ class ContactCoordinator @Inject constructor(
313304
deltaResult.fold(
314305
onSuccess = {
315306
trace(tag = TAG, message = "Delta upload successful", type = TraceType.Process)
316-
persistSyncState(dao, newChecksum)
307+
contactDataSource.persistSyncState(newChecksum)
317308
},
318309
onFailure = { deltaError ->
319310
if (deltaError is DeltaUploadError.ChecksumDrift || deltaError is DeltaUploadError.ChecksumMismatch) {
320-
performFullUpload(newE164s, newChecksum, dao)
311+
performFullUpload(newE164s, newChecksum)
321312
} else {
322313
trace(tag = TAG, message = "Delta upload failed: ${deltaError.message}", type = TraceType.Error)
323314
}
324315
}
325316
)
326317
} else {
327-
performFullUpload(newE164s, newChecksum, dao)
318+
performFullUpload(newE164s, newChecksum)
328319
}
329320
}
330321
else -> {
331322
// First sync or other error — full upload
332-
performFullUpload(newE164s, newChecksum, dao)
323+
performFullUpload(newE164s, newChecksum)
333324
}
334325
}
335326
}
336327
)
337328

338329
// 6. GetFlipcashContacts
339-
fetchFlipcashContacts(newChecksum, dao)
330+
fetchFlipcashContacts(newChecksum)
340331

341332
_state.update { it.copy(syncState = SyncState.Synced, hasEverSynced = true) }
342333
trace(tag = TAG, message = "Contact sync complete", type = TraceType.Process)
@@ -352,7 +343,6 @@ class ContactCoordinator @Inject constructor(
352343
private suspend fun performFullUpload(
353344
e164s: Set<String>,
354345
checksum: Checksum,
355-
dao: com.flipcash.app.persistence.dao.ContactDao,
356346
) {
357347
val phones = e164s.map { ContactMethod.Phone(it) }
358348
val chunked = phones.chunked(500)
@@ -365,45 +355,30 @@ class ContactCoordinator @Inject constructor(
365355
result.fold(
366356
onSuccess = {
367357
trace(tag = TAG, message = "Full upload successful (${e164s.size} contacts)", type = TraceType.Process)
368-
persistSyncState(dao, checksum)
358+
contactDataSource.persistSyncState(checksum)
369359
},
370360
onFailure = { error ->
371361
trace(tag = TAG, message = "Full upload failed: ${error.message}", type = TraceType.Error)
372362
}
373363
)
374364
}
375365

376-
private suspend fun persistSyncState(
377-
dao: com.flipcash.app.persistence.dao.ContactDao,
378-
checksum: Checksum,
379-
) {
380-
dao.upsertSyncState(
381-
ContactSyncStateEntity(
382-
checksumBytes = checksum.byteArray,
383-
lastSyncTimestamp = System.currentTimeMillis(),
384-
)
385-
)
386-
}
387-
388-
private suspend fun fetchFlipcashContacts(
389-
checksum: Checksum,
390-
dao: com.flipcash.app.persistence.dao.ContactDao,
391-
) {
366+
private suspend fun fetchFlipcashContacts(checksum: Checksum) {
392367
try {
393368
val result = contactListController.getFlipcashContacts(checksum)
394369
.firstOrNull()
395370

396371
result?.onSuccess { phones ->
397372
val flipcashE164s = phones.map { it.phoneNumber }.toSet()
398-
dao.clearFlipcashStatus()
373+
contactDataSource.clearFlipcashStatus()
399374
if (flipcashE164s.isNotEmpty()) {
400-
dao.markAsFlipcash(flipcashE164s.toList())
375+
contactDataSource.markAsFlipcash(flipcashE164s.toList())
401376
}
402377
_state.update { it.copy(flipcashE164s = flipcashE164s) }
403378
trace(tag = TAG, message = "Found ${flipcashE164s.size} contacts on Flipcash", type = TraceType.Process)
404379
}?.onFailure { error ->
405380
if (error is GetContactsError.NotFound) {
406-
dao.clearFlipcashStatus()
381+
contactDataSource.clearFlipcashStatus()
407382
_state.update { it.copy(flipcashE164s = emptySet()) }
408383
trace(tag = TAG, message = "No contacts on Flipcash yet", type = TraceType.Process)
409384
} else {

apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,9 @@ interface ContactDao {
4242
@Insert(onConflict = OnConflictStrategy.REPLACE)
4343
suspend fun upsertMappings(mappings: List<ContactMappingEntity>)
4444

45+
@Query("SELECT displayName FROM contact_mapping WHERE e164 = :e164 LIMIT 1")
46+
suspend fun getDisplayName(e164: String): String?
47+
4548
@Query("DELETE FROM contact_mapping WHERE e164 IN (:e164s)")
4649
suspend fun deleteMappings(e164s: List<String>)
4750

apps/flipcash/shared/persistence/sources/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ dependencies {
2424
implementation(project(":apps:flipcash:shared:persistence:db"))
2525

2626
implementation(project(":libs:encryption:base58"))
27+
implementation(project(":libs:encryption:keys"))
2728
implementation(project(":libs:encryption:utils"))
2829

2930
implementation(project(":services:flipcash"))
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
package com.flipcash.app.persistence.sources
2+
3+
import com.flipcash.app.persistence.FlipcashDatabase
4+
import com.flipcash.app.persistence.entities.ContactMappingEntity
5+
import com.flipcash.app.persistence.entities.ContactSyncStateEntity
6+
import com.flipcash.services.persistence.SingularDataSource
7+
import com.getcode.solana.keys.Checksum
8+
import kotlinx.coroutines.flow.Flow
9+
import kotlinx.coroutines.flow.emptyFlow
10+
import kotlinx.coroutines.flow.map
11+
import javax.inject.Inject
12+
import javax.inject.Singleton
13+
14+
@Singleton
15+
class ContactDataSource @Inject constructor(
16+
) : SingularDataSource<String, ContactMappingEntity, List<ContactMappingEntity>> {
17+
18+
private val db: FlipcashDatabase?
19+
get() = FlipcashDatabase.getInstance()
20+
21+
// region SingularDataSource
22+
23+
override suspend fun getById(id: String): ContactMappingEntity? =
24+
get().firstOrNull { it.e164 == id }
25+
26+
override suspend fun get(): List<ContactMappingEntity> =
27+
db?.contactDao()?.getAllMappings() ?: emptyList()
28+
29+
override suspend fun upsert(value: List<ContactMappingEntity>) {
30+
db?.contactDao()?.upsertMappings(value)
31+
}
32+
33+
override suspend fun query(whereClause: String): List<ContactMappingEntity> = emptyList()
34+
35+
override suspend fun getMostRecent(): ContactMappingEntity? = null
36+
37+
override suspend fun clear() {
38+
db?.contactDao()?.clearAll()
39+
}
40+
41+
override fun observe(): Flow<List<ContactMappingEntity>> =
42+
db?.contactDao()?.observeAllMappings() ?: emptyFlow()
43+
44+
override fun observe(id: String): Flow<ContactMappingEntity?> =
45+
observe().map { mappings -> mappings.firstOrNull { it.e164 == id } }
46+
47+
// endregion
48+
49+
// region Sync state
50+
51+
suspend fun getSyncState(): ContactSyncStateEntity? =
52+
db?.contactDao()?.getSyncState()
53+
54+
suspend fun persistSyncState(checksum: Checksum) {
55+
db?.contactDao()?.upsertSyncState(
56+
ContactSyncStateEntity(
57+
checksumBytes = checksum.byteArray,
58+
lastSyncTimestamp = System.currentTimeMillis(),
59+
)
60+
)
61+
}
62+
63+
// endregion
64+
65+
// region Contact-specific operations
66+
67+
suspend fun deleteMappings(e164s: List<String>) {
68+
db?.contactDao()?.deleteMappings(e164s)
69+
}
70+
71+
suspend fun clearFlipcashStatus() {
72+
db?.contactDao()?.clearFlipcashStatus()
73+
}
74+
75+
suspend fun markAsFlipcash(e164s: List<String>) {
76+
db?.contactDao()?.markAsFlipcash(e164s)
77+
}
78+
79+
suspend fun getDisplayName(e164: String): String? =
80+
db?.contactDao()?.getDisplayName(e164)
81+
82+
// endregion
83+
}

0 commit comments

Comments
 (0)