From d2d9444df2c314a2b9c72e1fd6c83e68c96371e1 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Fri, 29 May 2026 15:54:09 -0400 Subject: [PATCH] feat(contacts): clear server contact set on permission revoke and account deletion Wipe the server-side contact set when READ_CONTACTS is revoked (checked on each foreground) and before account deletion while the session can still authenticate. Also fix ContactDao.clearAll() to actually delete contact mapping rows, fix the picker overwrite bug (merge vs replace), add hasEverSynced tracking, and add removeContact/removeSelectedContact support. Signed-off-by: Brandon McAnsh --- .../shared/authentication/build.gradle.kts | 1 + .../com/flipcash/app/auth/AuthManager.kt | 4 ++ .../app/contacts/ContactCoordinator.kt | 71 +++++++++++++++++-- .../device/ScopeAwareContactReader.kt | 26 ++++++- .../device/internal/PickerContactReader.kt | 6 ++ .../app/persistence/dao/ContactDao.kt | 5 +- 6 files changed, 106 insertions(+), 7 deletions(-) diff --git a/apps/flipcash/shared/authentication/build.gradle.kts b/apps/flipcash/shared/authentication/build.gradle.kts index de4538db8..340df9402 100644 --- a/apps/flipcash/shared/authentication/build.gradle.kts +++ b/apps/flipcash/shared/authentication/build.gradle.kts @@ -12,6 +12,7 @@ dependencies { implementation(libs.androidx.datastore) implementation(project(":apps:flipcash:shared:appsettings")) + implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:persistence:provider")) implementation(project(":apps:flipcash:shared:push")) implementation(project(":apps:flipcash:shared:featureflags")) diff --git a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt index 7ce6d815d..348ce032c 100644 --- a/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt +++ b/apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt @@ -4,6 +4,7 @@ import androidx.core.app.NotificationManagerCompat import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.auth.internal.credentials.LookupResult 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.app.push.PushTokenProvider @@ -43,6 +44,7 @@ class AuthManager @Inject constructor( private val featureFlagController: FeatureFlagController, private val appSettings: AppSettingsCoordinator, private val userFlags: UserFlagsCoordinator, + private val contactCoordinator: ContactCoordinator, // private val analytics: AnalyticsService, ) : CoroutineScope by CoroutineScope(Dispatchers.IO) { private var softLoginDisabled: Boolean = false @@ -202,6 +204,8 @@ class AuthManager @Inject constructor( suspend fun deleteAndLogout(): Result { //todo: add account deletion + // Wipe server contact set before logout while the session can still authenticate. + contactCoordinator.clearServerContactSet() return logout() } 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 1d9ae379e..e377d5b7b 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 @@ -64,6 +64,7 @@ class ContactCoordinator @Inject constructor( val contacts: Map = emptyMap(), val flipcashE164s: Set = emptySet(), val syncState: SyncState = SyncState.Idle, + val hasEverSynced: Boolean = false, ) enum class SyncState { Idle, Syncing, Synced, Error } @@ -105,6 +106,7 @@ 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() } @@ -130,6 +132,18 @@ class ContactCoordinator @Inject constructor( return performSync() } + suspend fun removeContact(e164: String) { + contactReader.removeSelectedContact(e164) + val db = FlipcashDatabase.getInstance() ?: return + db.contactDao().deleteMappings(listOf(e164)) + _state.update { state -> + state.copy( + contacts = state.contacts - e164, + flipcashE164s = state.flipcashE164s - e164, + ) + } + } + suspend fun resolve(e164: String): Result { return resolverController.resolve(ContactMethod.Phone(e164)) } @@ -144,14 +158,60 @@ class ContactCoordinator @Inject constructor( trace(tag = TAG, message = "reset complete", type = TraceType.Process) } + /** + * Detects a contacts-permission revoke and wipes the server's stored + * contact set. A non-null checksum means we previously uploaded; if + * READ_CONTACTS is now denied, wipe the server set. Idempotent: a + * successful wipe clears the checksum; a failure leaves it intact so + * the next foreground retries. + */ + suspend fun clearServerContactSetIfRevoked() { + val db = FlipcashDatabase.getInstance() ?: return + val syncState = db.contactDao().getSyncState() ?: return + if (syncState.checksumBytes.all { it == 0.toByte() }) return + + if (!contactReader.isPermissionRevoked()) return + + clearServerContactSet() + _state.value = ContactState() + contactReader.reset() + db.contactDao().clearAll() + trace(tag = TAG, message = "Cleared server contact set after permission revoke", type = TraceType.Process) + } + + /** + * Sends an empty full upload to wipe the server-side contact set. + * Best-effort — failures are logged but not propagated. + * Must be called while the session is still authenticated. + */ + suspend fun clearServerContactSet() { + try { + val emptyChecksum = ContactChecksum.compute(emptySet()) + contactListController.fullUpload( + phones = kotlinx.coroutines.flow.flowOf(emptyList()), + expectedChecksum = emptyChecksum, + ) + } catch (e: Exception) { + trace(tag = TAG, message = "Failed to clear server contact set: ${e.message}", type = TraceType.Error) + } + } + // endregion // region Internal private suspend fun hydrateFromPersistence() { val db = FlipcashDatabase.getInstance() ?: return + val syncState = db.contactDao().getSyncState() val mappings = db.contactDao().getAllMappings() - if (mappings.isEmpty()) return + + val hasEverSynced = syncState != null || mappings.isNotEmpty() + if (mappings.isEmpty()) { + if (hasEverSynced) { + _state.update { it.copy(hasEverSynced = true) } + } + return + } val contacts = mappings.associate { mapping -> mapping.e164 to DeviceContact( @@ -165,7 +225,7 @@ class ContactCoordinator @Inject constructor( val flipcashE164s = mappings.filter { it.isOnFlipcash }.map { it.e164 }.toSet() _state.update { - it.copy(contacts = contacts, flipcashE164s = flipcashE164s) + it.copy(contacts = contacts, flipcashE164s = flipcashE164s, hasEverSynced = true) } trace(tag = TAG, message = "Hydrated ${mappings.size} contacts from persistence", type = TraceType.Process) @@ -220,11 +280,12 @@ class ContactCoordinator @Inject constructor( dao.deleteMappings(removes.toList()) } - // Update in-memory contacts with displayNumber + // Update in-memory contacts with displayNumber, merging into existing state + // so persisted contacts aren't lost when the picker returns only new picks. val enrichedContacts = deviceContacts.mapValues { (_, contact) -> contact.copy(displayNumber = phoneUtils.formatNumber(contact.e164)) } - _state.update { it.copy(contacts = enrichedContacts) } + _state.update { it.copy(contacts = it.contacts + enrichedContacts) } // 5. CheckSync with server val syncState = dao.getSyncState() @@ -277,7 +338,7 @@ class ContactCoordinator @Inject constructor( // 6. GetFlipcashContacts fetchFlipcashContacts(newChecksum, dao) - _state.update { it.copy(syncState = SyncState.Synced) } + _state.update { it.copy(syncState = SyncState.Synced, hasEverSynced = true) } trace(tag = TAG, message = "Contact sync complete", type = TraceType.Process) return Result.success(Unit) diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt index 33b080fa6..9ced7b178 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt @@ -14,16 +14,40 @@ class ScopeAwareContactReader @Inject constructor( private val featureFlags: FeatureFlagController, ) : DeviceContactReader { - override suspend fun readAll(): Result> = activeReader().readAll() + override suspend fun readAll(): Result> { + val reader = activeReader() + val result = reader.readAll() + // If full-access failed (no permission) but the picker has contacts, use those. + if (result.isFailure && reader === fullAccess) { + val pickerResult = picker.readAll() + if (pickerResult.isSuccess && pickerResult.getOrThrow().isNotEmpty()) { + return pickerResult + } + } + return result + } fun addSelectedContacts(contacts: List) { picker.addPickedContacts(contacts) } + fun removeSelectedContact(e164: String) { + picker.removePickedContact(e164) + } + fun reset() { picker.clearPickedContacts() } + /** + * Returns true if READ_CONTACTS was previously used but is now denied. + * Always false in picker mode — picker never holds READ_CONTACTS. + */ + suspend fun isPermissionRevoked(): Boolean { + if (featureFlags.observe(FeatureFlag.ContactPickerMode).value) return false + return fullAccess.readAll().isFailure + } + private fun activeReader(): DeviceContactReader = if (featureFlags.observe(FeatureFlag.ContactPickerMode).value) picker else fullAccess } diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt index 4cb29fe38..6f8bce56f 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt @@ -20,6 +20,12 @@ class PickerContactReader @Inject constructor( pickedContacts.update { it + contacts } } + fun removePickedContact(e164: String) { + pickedContacts.update { list -> + list.filterNot { normalizeToE164(it.phoneNumber) == e164 } + } + } + fun clearPickedContacts() { pickedContacts.value = emptyList() } 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 3175f0199..95d77236d 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 @@ -53,9 +53,12 @@ interface ContactDao { // endregion + @Query("DELETE FROM contact_mapping") + suspend fun deleteAllMappings() + @Transaction suspend fun clearAll() { clearSyncState() - clearFlipcashStatus() + deleteAllMappings() } }