Skip to content

Commit 1aa2c0b

Browse files
authored
feat(contacts): clear server contact set on permission revoke and account deletion (#800)
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 <git@bmcreations.dev>
1 parent 54d5f1a commit 1aa2c0b

6 files changed

Lines changed: 106 additions & 7 deletions

File tree

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ dependencies {
1212
implementation(libs.androidx.datastore)
1313

1414
implementation(project(":apps:flipcash:shared:appsettings"))
15+
implementation(project(":apps:flipcash:shared:contacts"))
1516
implementation(project(":apps:flipcash:shared:persistence:provider"))
1617
implementation(project(":apps:flipcash:shared:push"))
1718
implementation(project(":apps:flipcash:shared:featureflags"))

apps/flipcash/shared/authentication/src/main/kotlin/com/flipcash/app/auth/AuthManager.kt

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import androidx.core.app.NotificationManagerCompat
44
import com.flipcash.app.appsettings.AppSettingsCoordinator
55
import com.flipcash.app.auth.internal.credentials.LookupResult
66
import com.flipcash.app.auth.internal.credentials.PassphraseCredentialManager
7+
import com.flipcash.app.contacts.ContactCoordinator
78
import com.flipcash.app.featureflags.FeatureFlagController
89
import com.flipcash.app.persistence.PersistenceProvider
910
import com.flipcash.app.push.PushTokenProvider
@@ -43,6 +44,7 @@ class AuthManager @Inject constructor(
4344
private val featureFlagController: FeatureFlagController,
4445
private val appSettings: AppSettingsCoordinator,
4546
private val userFlags: UserFlagsCoordinator,
47+
private val contactCoordinator: ContactCoordinator,
4648
// private val analytics: AnalyticsService,
4749
) : CoroutineScope by CoroutineScope(Dispatchers.IO) {
4850
private var softLoginDisabled: Boolean = false
@@ -202,6 +204,8 @@ class AuthManager @Inject constructor(
202204

203205
suspend fun deleteAndLogout(): Result<Unit> {
204206
//todo: add account deletion
207+
// Wipe server contact set before logout while the session can still authenticate.
208+
contactCoordinator.clearServerContactSet()
205209
return logout()
206210
}
207211

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

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ class ContactCoordinator @Inject constructor(
6464
val contacts: Map<String, DeviceContact> = emptyMap(),
6565
val flipcashE164s: Set<String> = emptySet(),
6666
val syncState: SyncState = SyncState.Idle,
67+
val hasEverSynced: Boolean = false,
6768
)
6869

6970
enum class SyncState { Idle, Syncing, Synced, Error }
@@ -105,6 +106,7 @@ class ContactCoordinator @Inject constructor(
105106

106107
override fun onStart(owner: LifecycleOwner) {
107108
if (cluster.value != null) {
109+
scope.launch { clearServerContactSetIfRevoked() }
108110
trace(tag = TAG, message = "Lifecycle resumed, triggering contact sync", type = TraceType.Process)
109111
launchSync()
110112
}
@@ -130,6 +132,18 @@ class ContactCoordinator @Inject constructor(
130132
return performSync()
131133
}
132134

135+
suspend fun removeContact(e164: String) {
136+
contactReader.removeSelectedContact(e164)
137+
val db = FlipcashDatabase.getInstance() ?: return
138+
db.contactDao().deleteMappings(listOf(e164))
139+
_state.update { state ->
140+
state.copy(
141+
contacts = state.contacts - e164,
142+
flipcashE164s = state.flipcashE164s - e164,
143+
)
144+
}
145+
}
146+
133147
suspend fun resolve(e164: String): Result<PublicKey> {
134148
return resolverController.resolve(ContactMethod.Phone(e164))
135149
}
@@ -144,14 +158,60 @@ class ContactCoordinator @Inject constructor(
144158
trace(tag = TAG, message = "reset complete", type = TraceType.Process)
145159
}
146160

161+
/**
162+
* Detects a contacts-permission revoke and wipes the server's stored
163+
* contact set. A non-null checksum means we previously uploaded; if
164+
* READ_CONTACTS is now denied, wipe the server set. Idempotent: a
165+
* successful wipe clears the checksum; a failure leaves it intact so
166+
* the next foreground retries.
167+
*/
168+
suspend fun clearServerContactSetIfRevoked() {
169+
val db = FlipcashDatabase.getInstance() ?: return
170+
val syncState = db.contactDao().getSyncState() ?: return
171+
if (syncState.checksumBytes.all { it == 0.toByte() }) return
172+
173+
if (!contactReader.isPermissionRevoked()) return
174+
175+
clearServerContactSet()
176+
_state.value = ContactState()
177+
contactReader.reset()
178+
db.contactDao().clearAll()
179+
trace(tag = TAG, message = "Cleared server contact set after permission revoke", type = TraceType.Process)
180+
}
181+
182+
/**
183+
* Sends an empty full upload to wipe the server-side contact set.
184+
* Best-effort — failures are logged but not propagated.
185+
* Must be called while the session is still authenticated.
186+
*/
187+
suspend fun clearServerContactSet() {
188+
try {
189+
val emptyChecksum = ContactChecksum.compute(emptySet())
190+
contactListController.fullUpload(
191+
phones = kotlinx.coroutines.flow.flowOf(emptyList()),
192+
expectedChecksum = emptyChecksum,
193+
)
194+
} catch (e: Exception) {
195+
trace(tag = TAG, message = "Failed to clear server contact set: ${e.message}", type = TraceType.Error)
196+
}
197+
}
198+
147199
// endregion
148200

149201
// region Internal
150202

151203
private suspend fun hydrateFromPersistence() {
152204
val db = FlipcashDatabase.getInstance() ?: return
205+
val syncState = db.contactDao().getSyncState()
153206
val mappings = db.contactDao().getAllMappings()
154-
if (mappings.isEmpty()) return
207+
208+
val hasEverSynced = syncState != null || mappings.isNotEmpty()
209+
if (mappings.isEmpty()) {
210+
if (hasEverSynced) {
211+
_state.update { it.copy(hasEverSynced = true) }
212+
}
213+
return
214+
}
155215

156216
val contacts = mappings.associate { mapping ->
157217
mapping.e164 to DeviceContact(
@@ -165,7 +225,7 @@ class ContactCoordinator @Inject constructor(
165225
val flipcashE164s = mappings.filter { it.isOnFlipcash }.map { it.e164 }.toSet()
166226

167227
_state.update {
168-
it.copy(contacts = contacts, flipcashE164s = flipcashE164s)
228+
it.copy(contacts = contacts, flipcashE164s = flipcashE164s, hasEverSynced = true)
169229
}
170230

171231
trace(tag = TAG, message = "Hydrated ${mappings.size} contacts from persistence", type = TraceType.Process)
@@ -220,11 +280,12 @@ class ContactCoordinator @Inject constructor(
220280
dao.deleteMappings(removes.toList())
221281
}
222282

223-
// Update in-memory contacts with displayNumber
283+
// Update in-memory contacts with displayNumber, merging into existing state
284+
// so persisted contacts aren't lost when the picker returns only new picks.
224285
val enrichedContacts = deviceContacts.mapValues { (_, contact) ->
225286
contact.copy(displayNumber = phoneUtils.formatNumber(contact.e164))
226287
}
227-
_state.update { it.copy(contacts = enrichedContacts) }
288+
_state.update { it.copy(contacts = it.contacts + enrichedContacts) }
228289

229290
// 5. CheckSync with server
230291
val syncState = dao.getSyncState()
@@ -277,7 +338,7 @@ class ContactCoordinator @Inject constructor(
277338
// 6. GetFlipcashContacts
278339
fetchFlipcashContacts(newChecksum, dao)
279340

280-
_state.update { it.copy(syncState = SyncState.Synced) }
341+
_state.update { it.copy(syncState = SyncState.Synced, hasEverSynced = true) }
281342
trace(tag = TAG, message = "Contact sync complete", type = TraceType.Process)
282343
return Result.success(Unit)
283344

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

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,40 @@ class ScopeAwareContactReader @Inject constructor(
1414
private val featureFlags: FeatureFlagController,
1515
) : DeviceContactReader {
1616

17-
override suspend fun readAll(): Result<Map<String, DeviceContact>> = activeReader().readAll()
17+
override suspend fun readAll(): Result<Map<String, DeviceContact>> {
18+
val reader = activeReader()
19+
val result = reader.readAll()
20+
// If full-access failed (no permission) but the picker has contacts, use those.
21+
if (result.isFailure && reader === fullAccess) {
22+
val pickerResult = picker.readAll()
23+
if (pickerResult.isSuccess && pickerResult.getOrThrow().isNotEmpty()) {
24+
return pickerResult
25+
}
26+
}
27+
return result
28+
}
1829

1930
fun addSelectedContacts(contacts: List<PickedContactData>) {
2031
picker.addPickedContacts(contacts)
2132
}
2233

34+
fun removeSelectedContact(e164: String) {
35+
picker.removePickedContact(e164)
36+
}
37+
2338
fun reset() {
2439
picker.clearPickedContacts()
2540
}
2641

42+
/**
43+
* Returns true if READ_CONTACTS was previously used but is now denied.
44+
* Always false in picker mode — picker never holds READ_CONTACTS.
45+
*/
46+
suspend fun isPermissionRevoked(): Boolean {
47+
if (featureFlags.observe(FeatureFlag.ContactPickerMode).value) return false
48+
return fullAccess.readAll().isFailure
49+
}
50+
2751
private fun activeReader(): DeviceContactReader =
2852
if (featureFlags.observe(FeatureFlag.ContactPickerMode).value) picker else fullAccess
2953
}

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ class PickerContactReader @Inject constructor(
2020
pickedContacts.update { it + contacts }
2121
}
2222

23+
fun removePickedContact(e164: String) {
24+
pickedContacts.update { list ->
25+
list.filterNot { normalizeToE164(it.phoneNumber) == e164 }
26+
}
27+
}
28+
2329
fun clearPickedContacts() {
2430
pickedContacts.value = emptyList()
2531
}

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,12 @@ interface ContactDao {
5353

5454
// endregion
5555

56+
@Query("DELETE FROM contact_mapping")
57+
suspend fun deleteAllMappings()
58+
5659
@Transaction
5760
suspend fun clearAll() {
5861
clearSyncState()
59-
clearFlipcashStatus()
62+
deleteAllMappings()
6063
}
6164
}

0 commit comments

Comments
 (0)