Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/flipcash/shared/authentication/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -202,6 +204,8 @@ class AuthManager @Inject constructor(

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class ContactCoordinator @Inject constructor(
val contacts: Map<String, DeviceContact> = emptyMap(),
val flipcashE164s: Set<String> = emptySet(),
val syncState: SyncState = SyncState.Idle,
val hasEverSynced: Boolean = false,
)

enum class SyncState { Idle, Syncing, Synced, Error }
Expand Down Expand Up @@ -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()
}
Expand All @@ -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<PublicKey> {
return resolverController.resolve(ContactMethod.Phone(e164))
}
Expand All @@ -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(
Expand All @@ -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)
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,16 +14,40 @@ class ScopeAwareContactReader @Inject constructor(
private val featureFlags: FeatureFlagController,
) : DeviceContactReader {

override suspend fun readAll(): Result<Map<String, DeviceContact>> = activeReader().readAll()
override suspend fun readAll(): Result<Map<String, DeviceContact>> {
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<PickedContactData>) {
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -53,9 +53,12 @@ interface ContactDao {

// endregion

@Query("DELETE FROM contact_mapping")
suspend fun deleteAllMappings()

@Transaction
suspend fun clearAll() {
clearSyncState()
clearFlipcashStatus()
deleteAllMappings()
}
}
Loading