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() } }