From 8a63c0db59f27171071ab297aeb3457a8881bb74 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 26 May 2026 21:18:38 -0400 Subject: [PATCH 1/3] feat(verification): support full-screen and target navigation for drop-in use cases Add `target` and `fullScreen` params to AppRoute.Verification so the flow can forward-navigate on success (replace) and render with status bar padding when used outside of a modal sheet (e.g. onboarding). Wire phone verification into the onboarding flow from AccessKeyScreen. Co-Authored-By: Claude Opus 4.6 Signed-off-by: Brandon McAnsh --- .../main/kotlin/com/flipcash/app/core/AppRoute.kt | 4 ++++ .../contact/verification/VerificationFlowScreen.kt | 12 ++++++++---- .../contact/verification/phone/PhoneCodeScreen.kt | 3 ++- .../verification/phone/PhoneCountryCodeScreen.kt | 4 ++-- .../verification/phone/PhoneVerificationScreen.kt | 4 ++-- 5 files changed, 18 insertions(+), 9 deletions(-) diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt index 5dcf0cb06..75e154124 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/AppRoute.kt @@ -49,6 +49,8 @@ sealed interface AppRoute : NavKey, Parcelable { @Serializable data class Purchase(val fromLogin: Boolean = false) : Onboarding + @Serializable + data class ContactPermission(val postCreate: Boolean): Onboarding @Serializable data class NotificationPermission(val postCreate: Boolean = false) : Onboarding @Serializable @@ -88,6 +90,8 @@ sealed interface AppRoute : NavKey, Parcelable { val includeEmail: Boolean = true, val email: String? = null, val emailVerificationCode: String? = null, + val target: AppRoute? = null, + val fullScreen: Boolean = false, ) : AppRoute, FlowRouteWithResult { override val initialStack: List get() = buildVerificationInitialStack( diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt index 1b522fc59..836b1b355 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/VerificationFlowScreen.kt @@ -46,7 +46,11 @@ fun VerificationFlowScreen( route = route, value = NavResultOrCanceled.ReturnValue(result), ) - outerNavigator.pop() + if (route.target != null && result is VerificationResult.Success) { + outerNavigator.replace(route.target!!) + } else { + outerNavigator.pop() + } }, entryProvider = verificationEntryProvider(route), ) @@ -59,13 +63,13 @@ private fun verificationEntryProvider( VerificationFlowIntroContent(isForOnRamp = step.isForOnRamp) } annotatedEntry { - PhoneVerificationContent() + PhoneVerificationContent(isInModal = !route.fullScreen) } annotatedEntry { - PhoneCodeContent(includeEmail = route.includeEmail) + PhoneCodeContent(includeEmail = route.includeEmail, isInModal = !route.fullScreen) } annotatedEntry { - PhoneCountryCodeContent() + PhoneCountryCodeContent(isInModal = !route.fullScreen) } annotatedEntry { EmailVerificationContent( diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt index 8d28ddc34..0888c6847 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCodeScreen.kt @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.onEach @Composable fun PhoneCodeContent( includeEmail: Boolean, + isInModal: Boolean = true, ) { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() @@ -34,7 +35,7 @@ fun PhoneCodeContent( ) { AppBarWithTitle( title = stringResource(R.string.title_enterTheCode), - isInModal = true, + isInModal = isInModal, titleAlignment = Alignment.CenterHorizontally, backButton = true, onBackIconClicked = { flowNavigator.back() }, diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCountryCodeScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCountryCodeScreen.kt index bccc2855b..a9f0f81f7 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCountryCodeScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneCountryCodeScreen.kt @@ -20,7 +20,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @Composable -fun PhoneCountryCodeContent() { +fun PhoneCountryCodeContent(isInModal: Boolean = true) { val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() @@ -30,7 +30,7 @@ fun PhoneCountryCodeContent() { ) { AppBarWithTitle( title = stringResource(R.string.title_verifyPhoneNumber), - isInModal = true, + isInModal = isInModal, titleAlignment = Alignment.CenterHorizontally, backButton = true, onBackIconClicked = { flowNavigator.back() }, diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt index e252e620e..735540ff7 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/phone/PhoneVerificationScreen.kt @@ -25,7 +25,7 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach @Composable -fun PhoneVerificationContent() { +fun PhoneVerificationContent(isInModal: Boolean = true) { val codeNavigator = LocalCodeNavigator.current val flowNavigator = rememberFlowNavigator() val viewModel = flowSharedViewModel() @@ -37,7 +37,7 @@ fun PhoneVerificationContent() { ) { AppBarWithTitle( title = stringResource(R.string.title_verifyPhoneNumber), - isInModal = true, + isInModal = isInModal, titleAlignment = Alignment.CenterHorizontally, backButton = true, onBackIconClicked = { From 07e2c2834918978efc9436980e7a480563dd20b6 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 26 May 2026 21:23:38 -0400 Subject: [PATCH 2/3] feat(contacts): add shared contacts module with persistence layer Introduces the contacts module with: - ContactCoordinator for orchestrating contact sync - ScopeAwareContactReader with full-access and picker-based strategies - ContactChecksum for detecting contact list changes - Hilt module for DI wiring Adds persistence support: - ContactDao with Room queries for contact mappings and sync state - ContactMappingEntity and ContactSyncStateEntity - FlipcashDatabase v16 schema migration Signed-off-by: Brandon McAnsh --- apps/flipcash/app/build.gradle.kts | 1 + .../kotlin/com/flipcash/app/MainActivity.kt | 6 + apps/flipcash/shared/contacts/.gitignore | 2 + .../flipcash/shared/contacts/build.gradle.kts | 23 + .../contacts/src/main/AndroidManifest.xml | 8 + .../app/contacts/ContactCoordinator.kt | 347 +++++++++++++ .../contacts/device/DeviceContactReader.kt | 12 + .../device/ScopeAwareContactReader.kt | 29 ++ .../internal/FullAccessContactReader.kt | 81 +++ .../device/internal/PickerContactReader.kt | 93 ++++ .../app/contacts/inject/ContactModule.kt | 20 + .../app/contacts/sync/ContactChecksum.kt | 26 + .../app/contacts/sync/ContactChecksumTest.kt | 79 +++ .../flipcash/app/featureflags/FeatureFlag.kt | 11 + .../16.json | 469 ++++++++++++++++++ .../app/persistence/FlipcashDatabase.kt | 9 +- .../app/persistence/dao/ContactDao.kt | 61 +++ .../entities/ContactMappingEntity.kt | 14 + .../entities/ContactSyncStateEntity.kt | 30 ++ .../app/persistence/dao/ContactDaoTest.kt | 238 +++++++++ settings.gradle.kts | 1 + 21 files changed, 1559 insertions(+), 1 deletion(-) create mode 100644 apps/flipcash/shared/contacts/.gitignore create mode 100644 apps/flipcash/shared/contacts/build.gradle.kts create mode 100644 apps/flipcash/shared/contacts/src/main/AndroidManifest.xml create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/FullAccessContactReader.kt create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/inject/ContactModule.kt create mode 100644 apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/sync/ContactChecksum.kt create mode 100644 apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/sync/ContactChecksumTest.kt create mode 100644 apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/16.json create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt create mode 100644 apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactSyncStateEntity.kt create mode 100644 apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ContactDaoTest.kt diff --git a/apps/flipcash/app/build.gradle.kts b/apps/flipcash/app/build.gradle.kts index c67daeec3..8a6859bc5 100644 --- a/apps/flipcash/app/build.gradle.kts +++ b/apps/flipcash/app/build.gradle.kts @@ -150,6 +150,7 @@ dependencies { implementation(project(":apps:flipcash:shared:google-play-billing")) implementation(project(":apps:flipcash:shared:currency-selection:core")) implementation(project(":apps:flipcash:shared:currency-selection:ui")) + implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:notifications")) implementation(project(":apps:flipcash:shared:onramp:coinbase")) implementation(project(":apps:flipcash:shared:onramp:deeplinks")) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt index 4d4246556..696c414fc 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt @@ -16,6 +16,8 @@ import com.flipcash.app.appsettings.LocalAppSettings import com.flipcash.app.bill.customization.BillPlaygroundController import com.flipcash.app.bill.customization.LocalBillPlaygroundController import com.flipcash.app.billing.BillingClient +import com.flipcash.app.contacts.ContactCoordinator +import com.flipcash.app.contacts.LocalContactCoordinator import com.flipcash.app.core.LocalUserManager import com.flipcash.app.core.verification.email.EmailCodeChannel import com.flipcash.app.core.verification.email.LocalEmailCodeChannel @@ -115,6 +117,9 @@ class MainActivity : FragmentActivity() { @Inject lateinit var emailCodeChannel: EmailCodeChannel + @Inject + lateinit var contactCoordinator: ContactCoordinator + @Inject lateinit var coinbaseOnRampController: CoinbaseOnRampController @@ -141,6 +146,7 @@ class MainActivity : FragmentActivity() { LocalBillPlaygroundController provides billPlaygroundController, LocalAppUpdater provides appUpdater, LocalEmailCodeChannel provides emailCodeChannel, + LocalContactCoordinator provides contactCoordinator, LocalCoinbaseOnRampController provides coinbaseOnRampController, LocalUiTesting provides intent.getBooleanExtra(UI_TEST, false), ) { diff --git a/apps/flipcash/shared/contacts/.gitignore b/apps/flipcash/shared/contacts/.gitignore new file mode 100644 index 000000000..9f2a07880 --- /dev/null +++ b/apps/flipcash/shared/contacts/.gitignore @@ -0,0 +1,2 @@ +build/ +.gradle/ diff --git a/apps/flipcash/shared/contacts/build.gradle.kts b/apps/flipcash/shared/contacts/build.gradle.kts new file mode 100644 index 000000000..cca83f5fc --- /dev/null +++ b/apps/flipcash/shared/contacts/build.gradle.kts @@ -0,0 +1,23 @@ +plugins { + alias(libs.plugins.flipcash.android.feature) +} + +android { + namespace = "${Gradle.flipcashNamespace}.shared.contacts" +} + +dependencies { + testImplementation(kotlin("test")) + testImplementation(libs.bundles.unit.testing) + testImplementation(libs.robolectric) + + implementation(project(":services:flipcash")) + implementation(project(":services:opencode")) + implementation(project(":apps:flipcash:shared:persistence:db")) + implementation(project(":apps:flipcash:shared:phone")) + implementation(project(":apps:flipcash:shared:featureflags")) + implementation(project(":libs:encryption:keys")) + implementation(project(":libs:network:connectivity:public")) + implementation(libs.androidx.lifecycle.process) + implementation(libs.bundles.room) +} diff --git a/apps/flipcash/shared/contacts/src/main/AndroidManifest.xml b/apps/flipcash/shared/contacts/src/main/AndroidManifest.xml new file mode 100644 index 000000000..085db2815 --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/AndroidManifest.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file 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 new file mode 100644 index 000000000..d93df3c72 --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -0,0 +1,347 @@ +@file:OptIn(ExperimentalCoroutinesApi::class) + +package com.flipcash.app.contacts + +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.lifecycle.DefaultLifecycleObserver +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.ProcessLifecycleOwner +import com.flipcash.app.contacts.device.DeviceContact +import com.flipcash.app.contacts.device.ScopeAwareContactReader +import com.flipcash.app.contacts.sync.ContactChecksum +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ContactMappingEntity +import com.flipcash.app.persistence.entities.ContactSyncStateEntity +import com.flipcash.services.controllers.ContactListController +import com.flipcash.services.controllers.ResolverController +import com.flipcash.services.models.CheckSyncError +import com.flipcash.services.models.ContactMethod +import com.flipcash.services.models.DeltaUploadError +import com.getcode.opencode.model.accounts.AccountCluster +import com.getcode.opencode.providers.SessionListener +import com.getcode.solana.keys.Checksum +import com.getcode.solana.keys.PublicKey +import com.getcode.utils.TraceType +import com.getcode.utils.network.NetworkConnectivityListener +import com.getcode.utils.trace +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.filterNotNull +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ContactCoordinator @Inject constructor( + private val contactListController: ContactListController, + private val resolverController: ResolverController, + private val networkObserver: NetworkConnectivityListener, + private val contactReader: ScopeAwareContactReader, +) : SessionListener, DefaultLifecycleObserver { + + companion object { + private const val TAG = "ContactCoordinator" + } + + data class ContactState( + val contacts: Map = emptyMap(), + val flipcashE164s: Set = emptySet(), + val syncState: SyncState = SyncState.Idle, + ) + + enum class SyncState { Idle, Syncing, Synced, Error } + + private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + private val cluster = MutableStateFlow(null) + private val _state = MutableStateFlow(ContactState()) + private var syncJob: Job? = null + + val state: StateFlow + get() = _state.asStateFlow() + + // region SessionListener + + override suspend fun onUserLoggedIn(cluster: AccountCluster) { + trace(tag = TAG, message = "User logged in, hydrating contacts", type = TraceType.User) + this.cluster.value = cluster + hydrateFromPersistence() + sync() + } + + // endregion + + // region Lifecycle + + init { + ProcessLifecycleOwner.get().lifecycle.addObserver(this) + + cluster.filterNotNull() + .flatMapLatest { networkObserver.state } + .distinctUntilChanged() + .filter { it.connected } + .onEach { + trace(tag = TAG, message = "Network connected, triggering contact sync", type = TraceType.Process) + sync() + } + .launchIn(scope) + } + + override fun onStart(owner: LifecycleOwner) { + if (cluster.value != null) { + trace(tag = TAG, message = "Lifecycle resumed, triggering contact sync", type = TraceType.Process) + sync() + } + } + + override fun onStop(owner: LifecycleOwner) { + syncJob?.cancel() + } + + // endregion + + // region Public API + + fun sync() { + syncJob?.cancel() + syncJob = scope.launch { performSync() } + } + + fun addPickedContacts(contactIds: List) { + contactReader.addSelectedContacts(contactIds) + sync() + } + + suspend fun resolve(e164: String): Result { + return resolverController.resolve(ContactMethod.Phone(e164)) + } + + suspend fun reset() { + syncJob?.cancel() + _state.value = ContactState() + cluster.value = null + contactReader.reset() + val db = FlipcashDatabase.getInstance() ?: return + db.contactDao().clearAll() + trace(tag = TAG, message = "reset complete", type = TraceType.Process) + } + + // endregion + + // region Internal + + private suspend fun hydrateFromPersistence() { + val db = FlipcashDatabase.getInstance() ?: return + val mappings = db.contactDao().getAllMappings() + if (mappings.isEmpty()) return + + val contacts = mappings.associate { mapping -> + mapping.e164 to DeviceContact( + e164 = mapping.e164, + androidContactId = mapping.androidContactId, + displayName = mapping.displayName, + photoUri = mapping.photoUri, + ) + } + val flipcashE164s = mappings.filter { it.isOnFlipcash }.map { it.e164 }.toSet() + + _state.update { + it.copy(contacts = contacts, flipcashE164s = flipcashE164s) + } + + trace(tag = TAG, message = "Hydrated ${mappings.size} contacts from persistence", type = TraceType.Process) + } + + private suspend fun performSync() { + if (cluster.value == null) return + + _state.update { it.copy(syncState = SyncState.Syncing) } + + try { + // 1. Read device contacts + val deviceContacts = contactReader.readAll().getOrElse { error -> + trace(tag = TAG, message = "Cannot read contacts: ${error.message}", type = TraceType.Log) + return + } + + if (deviceContacts.isEmpty()) { + trace(tag = TAG, message = "No device contacts found", type = TraceType.Process) + _state.update { it.copy(syncState = SyncState.Synced) } + return + } + + // 2. Compute checksum + val newChecksum = ContactChecksum.compute(deviceContacts.keys) + + // 3. Diff against persisted mappings + val db = FlipcashDatabase.getInstance() ?: run { + _state.update { it.copy(syncState = SyncState.Error) } + return + } + val dao = db.contactDao() + val existingMappings = dao.getAllMappings() + val existingE164s = existingMappings.map { it.e164 }.toSet() + val newE164s = deviceContacts.keys + + val adds = newE164s - existingE164s + val removes = existingE164s - newE164s + + // 4. Persist new mappings + if (adds.isNotEmpty()) { + val newEntities = adds.mapNotNull { e164 -> + deviceContacts[e164]?.let { contact -> + ContactMappingEntity( + e164 = contact.e164, + androidContactId = contact.androidContactId, + displayName = contact.displayName, + photoUri = contact.photoUri, + ) + } + } + dao.upsertMappings(newEntities) + } + if (removes.isNotEmpty()) { + dao.deleteMappings(removes.toList()) + } + + // Update in-memory contacts + _state.update { it.copy(contacts = deviceContacts) } + + // 5. CheckSync with server + val syncState = dao.getSyncState() + val oldChecksum = syncState?.let { Checksum(it.checksumBytes.toList()) } + + val checkSyncResult = contactListController.checkSync(newChecksum) + + checkSyncResult.fold( + onSuccess = { serverChecksum -> + // Checksums match — skip upload + trace(tag = TAG, message = "Contacts in sync with server", type = TraceType.Process) + persistSyncState(dao, newChecksum) + }, + onFailure = { error -> + when (error) { + is CheckSyncError.OutOfSync -> { + // Try delta upload first + if (oldChecksum != null && adds.isNotEmpty() || removes.isNotEmpty()) { + val deltaResult = contactListController.deltaUpload( + adds = adds.map { ContactMethod.Phone(it) }, + removes = removes.map { ContactMethod.Phone(it) }, + oldChecksum = oldChecksum ?: newChecksum, + newChecksum = newChecksum, + ) + deltaResult.fold( + onSuccess = { + trace(tag = TAG, message = "Delta upload successful", type = TraceType.Process) + persistSyncState(dao, newChecksum) + }, + onFailure = { deltaError -> + if (deltaError is DeltaUploadError.ChecksumDrift || deltaError is DeltaUploadError.ChecksumMismatch) { + performFullUpload(newE164s, newChecksum, dao) + } else { + trace(tag = TAG, message = "Delta upload failed: ${deltaError.message}", type = TraceType.Error) + } + } + ) + } else { + performFullUpload(newE164s, newChecksum, dao) + } + } + else -> { + // First sync or other error — full upload + performFullUpload(newE164s, newChecksum, dao) + } + } + } + ) + + // 6. GetFlipcashContacts + fetchFlipcashContacts(newChecksum, dao) + + _state.update { it.copy(syncState = SyncState.Synced) } + trace(tag = TAG, message = "Contact sync complete", type = TraceType.Process) + + } catch (e: Exception) { + trace(tag = TAG, message = "Contact sync failed: ${e.message}", error = e, type = TraceType.Error) + _state.update { it.copy(syncState = SyncState.Error) } + } + } + + private suspend fun performFullUpload( + e164s: Set, + checksum: Checksum, + dao: com.flipcash.app.persistence.dao.ContactDao, + ) { + val phones = e164s.map { ContactMethod.Phone(it) } + val chunked = phones.chunked(500) + + val result = contactListController.fullUpload( + phones = kotlinx.coroutines.flow.flowOf(*chunked.toTypedArray()), + expectedChecksum = checksum, + ) + + result.fold( + onSuccess = { + trace(tag = TAG, message = "Full upload successful (${e164s.size} contacts)", type = TraceType.Process) + persistSyncState(dao, checksum) + }, + onFailure = { error -> + trace(tag = TAG, message = "Full upload failed: ${error.message}", type = TraceType.Error) + } + ) + } + + private suspend fun persistSyncState( + dao: com.flipcash.app.persistence.dao.ContactDao, + checksum: Checksum, + ) { + dao.upsertSyncState( + ContactSyncStateEntity( + checksumBytes = checksum.byteArray, + lastSyncTimestamp = System.currentTimeMillis(), + ) + ) + } + + private suspend fun fetchFlipcashContacts( + checksum: Checksum, + dao: com.flipcash.app.persistence.dao.ContactDao, + ) { + try { + val result = contactListController.getFlipcashContacts(checksum) + .firstOrNull() + + result?.onSuccess { phones -> + val flipcashE164s = phones.map { it.phoneNumber }.toSet() + dao.clearFlipcashStatus() + if (flipcashE164s.isNotEmpty()) { + dao.markAsFlipcash(flipcashE164s.toList()) + } + _state.update { it.copy(flipcashE164s = flipcashE164s) } + trace(tag = TAG, message = "Found ${flipcashE164s.size} contacts on Flipcash", type = TraceType.Process) + }?.onFailure { error -> + trace(tag = TAG, message = "GetFlipcashContacts failed: ${error.message}", type = TraceType.Error) + } + } catch (e: Exception) { + trace(tag = TAG, message = "GetFlipcashContacts exception: ${e.message}", error = e, type = TraceType.Error) + } + } + + // endregion +} + +val LocalContactCoordinator = staticCompositionLocalOf { + error("No ContactCoordinator provided") +} diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt new file mode 100644 index 000000000..6e6ef6901 --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactReader.kt @@ -0,0 +1,12 @@ +package com.flipcash.app.contacts.device + +interface DeviceContactReader { + suspend fun readAll(): Result> +} + +data class DeviceContact( + val e164: String, + val androidContactId: Long, + val displayName: String, + val photoUri: String?, +) 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 new file mode 100644 index 000000000..11c95585d --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/ScopeAwareContactReader.kt @@ -0,0 +1,29 @@ +package com.flipcash.app.contacts.device + +import com.flipcash.app.contacts.device.internal.FullAccessContactReader +import com.flipcash.app.contacts.device.internal.PickerContactReader +import com.flipcash.app.featureflags.FeatureFlag +import com.flipcash.app.featureflags.FeatureFlagController +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class ScopeAwareContactReader @Inject constructor( + private val fullAccess: FullAccessContactReader, + private val picker: PickerContactReader, + private val featureFlags: FeatureFlagController, +) : DeviceContactReader { + + override suspend fun readAll(): Result> = activeReader().readAll() + + fun addSelectedContacts(contactIds: List) { + picker.addPickedContacts(contactIds) + } + + fun reset() { + picker.clearPickedContacts() + } + + 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/FullAccessContactReader.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/FullAccessContactReader.kt new file mode 100644 index 000000000..89c804e64 --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/FullAccessContactReader.kt @@ -0,0 +1,81 @@ +package com.flipcash.app.contacts.device.internal + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.provider.ContactsContract +import androidx.core.content.ContextCompat +import com.flipcash.app.contacts.device.DeviceContact +import com.flipcash.app.contacts.device.DeviceContactReader +import com.flipcash.app.phone.PhoneUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject + +class FullAccessContactReader @Inject constructor( + @param:ApplicationContext private val context: Context, + private val phoneUtils: PhoneUtils, +) : DeviceContactReader { + + override suspend fun readAll(): Result> { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) != PackageManager.PERMISSION_GRANTED) { + return Result.failure(SecurityException("READ_CONTACTS not granted")) + } + + val result = mutableMapOf() + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.CONTACT_ID, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.PHOTO_URI, + ) + + context.contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + projection, + null, + null, + null, + )?.use { cursor -> + val numberIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + val contactIdIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + val nameIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) + val photoIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.PHOTO_URI) + + while (cursor.moveToNext()) { + val rawNumber = cursor.getString(numberIdx) ?: continue + val contactId = cursor.getLong(contactIdIdx) + val displayName = cursor.getString(nameIdx) ?: continue + val photoUri = cursor.getString(photoIdx) + + val e164 = normalizeToE164(rawNumber) ?: continue + + val existing = result[e164] + if (existing == null || (existing.photoUri == null && photoUri != null)) { + result[e164] = DeviceContact( + e164 = e164, + androidContactId = contactId, + displayName = displayName, + photoUri = photoUri, + ) + } + } + } + + return Result.success(result) + } + + private fun normalizeToE164(rawNumber: String): String? { + val cleaned = rawNumber.filter { it.isDigit() || it == '+' } + if (cleaned.isBlank()) return null + + val withPlus = if (cleaned.startsWith("+")) cleaned else "+$cleaned" + if (!phoneUtils.isPhoneNumberValid(withPlus)) return null + + val countryCode = phoneUtils.getCountryCode(withPlus.removePrefix("+")) + val locale = phoneUtils.countryLocales.find { it.countryCode == countryCode } + ?: phoneUtils.defaultCountryLocale + + val result = phoneUtils.cleanNumber(withPlus.removePrefix("+${locale.phoneCode}"), locale) + return result.ifBlank { null } + } +} 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 new file mode 100644 index 000000000..a2e46e0cf --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt @@ -0,0 +1,93 @@ +package com.flipcash.app.contacts.device.internal + +import android.content.Context +import android.provider.ContactsContract +import com.flipcash.app.contacts.device.DeviceContact +import com.flipcash.app.contacts.device.DeviceContactReader +import com.flipcash.app.phone.PhoneUtils +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class PickerContactReader @Inject constructor( + @param:ApplicationContext private val context: Context, + private val phoneUtils: PhoneUtils, +) : DeviceContactReader { + + private val pickedContactIds = MutableStateFlow>(emptySet()) + + fun addPickedContacts(contactIds: List) { + pickedContactIds.update { it + contactIds } + } + + fun clearPickedContacts() { + pickedContactIds.value = emptySet() + } + + override suspend fun readAll(): Result> { + val ids = pickedContactIds.value + if (ids.isEmpty()) return Result.success(emptyMap()) + + val result = mutableMapOf() + val projection = arrayOf( + ContactsContract.CommonDataKinds.Phone.NUMBER, + ContactsContract.CommonDataKinds.Phone.CONTACT_ID, + ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME, + ContactsContract.CommonDataKinds.Phone.PHOTO_URI, + ) + + val selection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} IN (${ids.joinToString(",")})" + + context.contentResolver.query( + ContactsContract.CommonDataKinds.Phone.CONTENT_URI, + projection, + selection, + null, + null, + )?.use { cursor -> + val numberIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER) + val contactIdIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID) + val nameIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME) + val photoIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.PHOTO_URI) + + while (cursor.moveToNext()) { + val rawNumber = cursor.getString(numberIdx) ?: continue + val contactId = cursor.getLong(contactIdIdx) + val displayName = cursor.getString(nameIdx) ?: continue + val photoUri = cursor.getString(photoIdx) + + val e164 = normalizeToE164(rawNumber) ?: continue + + val existing = result[e164] + if (existing == null || (existing.photoUri == null && photoUri != null)) { + result[e164] = DeviceContact( + e164 = e164, + androidContactId = contactId, + displayName = displayName, + photoUri = photoUri, + ) + } + } + } + + return Result.success(result) + } + + private fun normalizeToE164(rawNumber: String): String? { + val cleaned = rawNumber.filter { it.isDigit() || it == '+' } + if (cleaned.isBlank()) return null + + val withPlus = if (cleaned.startsWith("+")) cleaned else "+$cleaned" + if (!phoneUtils.isPhoneNumberValid(withPlus)) return null + + val countryCode = phoneUtils.getCountryCode(withPlus.removePrefix("+")) + val locale = phoneUtils.countryLocales.find { it.countryCode == countryCode } + ?: phoneUtils.defaultCountryLocale + + val result = phoneUtils.cleanNumber(withPlus.removePrefix("+${locale.phoneCode}"), locale) + return result.ifBlank { null } + } +} diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/inject/ContactModule.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/inject/ContactModule.kt new file mode 100644 index 000000000..f8cd7f868 --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/inject/ContactModule.kt @@ -0,0 +1,20 @@ +package com.flipcash.app.contacts.inject + +import com.flipcash.app.contacts.ContactCoordinator +import com.getcode.opencode.providers.SessionListener +import dagger.Binds +import dagger.Module +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import dagger.multibindings.IntoSet + +@Module +@InstallIn(SingletonComponent::class) +abstract class ContactModule { + + @Binds + @IntoSet + abstract fun bindSessionListener( + coordinator: ContactCoordinator + ): SessionListener +} diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/sync/ContactChecksum.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/sync/ContactChecksum.kt new file mode 100644 index 000000000..748bca089 --- /dev/null +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/sync/ContactChecksum.kt @@ -0,0 +1,26 @@ +package com.flipcash.app.contacts.sync + +import com.getcode.solana.keys.Checksum +import com.getcode.solana.keys.LENGTH_32 +import java.security.MessageDigest + +object ContactChecksum { + fun compute(e164s: Set): Checksum = Checksum(computeBytes(e164s).toList()) + + fun computeBytes(e164s: Set): ByteArray { + val xored = ByteArray(LENGTH_32) + if (e164s.isEmpty()) return xored + + val digest = MessageDigest.getInstance("SHA-256") + + for (e164 in e164s) { + val hash = digest.digest(e164.toByteArray(Charsets.UTF_8)) + for (i in xored.indices) { + xored[i] = (xored[i].toInt() xor hash[i].toInt()).toByte() + } + digest.reset() + } + + return xored + } +} diff --git a/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/sync/ContactChecksumTest.kt b/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/sync/ContactChecksumTest.kt new file mode 100644 index 000000000..54cbeef4d --- /dev/null +++ b/apps/flipcash/shared/contacts/src/test/kotlin/com/flipcash/app/contacts/sync/ContactChecksumTest.kt @@ -0,0 +1,79 @@ +package com.flipcash.app.contacts.sync + +import org.junit.Test +import kotlin.test.assertContentEquals +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class ContactChecksumTest { + + @Test + fun `empty set produces zero checksum`() { + val bytes = ContactChecksum.computeBytes(emptySet()) + assertEquals(32, bytes.size) + assertTrue(bytes.all { it == 0.toByte() }) + } + + @Test + fun `single entry produces non-zero checksum`() { + val bytes = ContactChecksum.computeBytes(setOf("+15551234567")) + assertEquals(32, bytes.size) + assertTrue(bytes.any { it != 0.toByte() }) + } + + @Test + fun `order does not matter`() { + val a = ContactChecksum.computeBytes(setOf("+15551234567", "+15559876543")) + val b = ContactChecksum.computeBytes(setOf("+15559876543", "+15551234567")) + assertContentEquals(a, b) + } + + @Test + fun `same input is deterministic`() { + val phones = setOf("+15551234567", "+15559876543", "+447911123456") + val a = ContactChecksum.computeBytes(phones) + val b = ContactChecksum.computeBytes(phones) + assertContentEquals(a, b) + } + + @Test + fun `different inputs produce different checksums`() { + val a = ContactChecksum.computeBytes(setOf("+15551234567")) + val b = ContactChecksum.computeBytes(setOf("+15559876543")) + assertTrue(!a.contentEquals(b)) + } + + @Test + fun `adding a contact changes checksum`() { + val base = setOf("+15551234567", "+15559876543") + val extended = base + "+447911123456" + + val a = ContactChecksum.computeBytes(base) + val b = ContactChecksum.computeBytes(extended) + assertTrue(!a.contentEquals(b)) + } + + @Test + fun `removing a contact changes checksum`() { + val full = setOf("+15551234567", "+15559876543", "+447911123456") + val reduced = full - "+15559876543" + + val a = ContactChecksum.computeBytes(full) + val b = ContactChecksum.computeBytes(reduced) + assertTrue(!a.contentEquals(b)) + } + + @Test + fun `XOR self-cancellation property`() { + val singleA = ContactChecksum.computeBytes(setOf("+15551234567")) + val singleB = ContactChecksum.computeBytes(setOf("+15559876543")) + val both = ContactChecksum.computeBytes(setOf("+15551234567", "+15559876543")) + + // XOR(both, singleB) should equal singleA + val result = ByteArray(32) + for (i in result.indices) { + result[i] = (both[i].toInt() xor singleB[i].toInt()).toByte() + } + assertContentEquals(singleA, result) + } +} diff --git a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt index 0e8642630..e64b02724 100644 --- a/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt +++ b/apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt @@ -170,6 +170,15 @@ sealed interface FeatureFlag { .map { FlagOption(it.name, it.label, isDisabled = it.duration == null) } } + @FeatureFlagMarker + data object ContactPickerMode : FeatureFlag { + override val key: String = "contact_picker_mode" + override val default: Boolean = false + override val launched: Boolean = false + override val visible: Boolean = true + override val persistLogOut: Boolean = true + } + @FeatureFlagMarker data object NavBar : FeatureFlag { override val key: String = "nav_bar_config" @@ -209,6 +218,7 @@ val FeatureFlag<*>.title: String FeatureFlag.BillTextures -> "Bill Textures" FeatureFlag.DepositUsdc -> "Deposit USDC" FeatureFlag.BackgroundReset -> "Background Reset" + FeatureFlag.ContactPickerMode -> "Contact Picker Mode" FeatureFlag.NavBar -> "Navigation Bar" } @@ -230,6 +240,7 @@ val FeatureFlag<*>.message: String FeatureFlag.BillTextures -> "When enabled, you'll gain the ability to select textures for bills during currency creation" FeatureFlag.DepositUsdc -> "When enabled, you'll gain the ability to deposit USDC directly from any external wallet app instead of purchasing a currency first and sell" FeatureFlag.BackgroundReset -> "Automatically returns the app to the camera screen after a period of inactivity with the app in the background" + FeatureFlag.ContactPickerMode -> "When enabled, contacts will be accessed via the system contact picker instead of requesting full READ_CONTACTS permission" FeatureFlag.NavBar -> "Customize the order and labels of navigation bar buttons" } diff --git a/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/16.json b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/16.json new file mode 100644 index 000000000..6666e5795 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/schemas/com.flipcash.app.persistence.FlipcashDatabase/16.json @@ -0,0 +1,469 @@ +{ + "formatVersion": 1, + "database": { + "version": 16, + "identityHash": "783158c45e37de0e5559998c10243663", + "entities": [ + { + "tableName": "messages", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`idBase58` TEXT NOT NULL, `text` TEXT NOT NULL, `amountUsdc` INTEGER, `amountNative` INTEGER, `nativeCurrency` TEXT, `rate` REAL, `state` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `metadata` TEXT, `mintBase58` TEXT DEFAULT 'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v', PRIMARY KEY(`idBase58`))", + "fields": [ + { + "fieldPath": "idBase58", + "columnName": "idBase58", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "amountUsdc", + "columnName": "amountUsdc", + "affinity": "INTEGER" + }, + { + "fieldPath": "amountNative", + "columnName": "amountNative", + "affinity": "INTEGER" + }, + { + "fieldPath": "nativeCurrency", + "columnName": "nativeCurrency", + "affinity": "TEXT" + }, + { + "fieldPath": "rate", + "columnName": "rate", + "affinity": "REAL" + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "metadata", + "columnName": "metadata", + "affinity": "TEXT" + }, + { + "fieldPath": "mintBase58", + "columnName": "mintBase58", + "affinity": "TEXT", + "defaultValue": "'EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v'" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "idBase58" + ] + } + }, + { + "tableName": "tokens", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`address` TEXT NOT NULL, `decimals` INTEGER NOT NULL, `name` TEXT NOT NULL, `symbol` TEXT NOT NULL, `created_at` INTEGER, `description` TEXT NOT NULL, `image_url` TEXT NOT NULL, `social_links` TEXT, `bill_customizations` TEXT, `holder_metrics` TEXT, `vm_vm` TEXT NOT NULL, `vm_authority` TEXT NOT NULL, `vm_lock_duration_days` INTEGER NOT NULL, `lp_currency_config` TEXT, `lp_liquidity_pool` TEXT, `lp_seed` TEXT, `lp_authority` TEXT, `lp_mint_vault` TEXT, `lp_core_mint_vault` TEXT, `lp_circulating_supply_quarks` INTEGER, `lp_sell_fee_bps` INTEGER, `lp_price_amount_usd` REAL, `lp_market_cap_amount_usd` REAL, PRIMARY KEY(`address`))", + "fields": [ + { + "fieldPath": "address", + "columnName": "address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "decimals", + "columnName": "decimals", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "symbol", + "columnName": "symbol", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdAt", + "columnName": "created_at", + "affinity": "INTEGER" + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "imageUrl", + "columnName": "image_url", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "socialLinks", + "columnName": "social_links", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizationsJson", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "holderMetricsJson", + "columnName": "holder_metrics", + "affinity": "TEXT" + }, + { + "fieldPath": "vmMetadata.vm", + "columnName": "vm_vm", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.authority", + "columnName": "vm_authority", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "vmMetadata.lockDurationInDays", + "columnName": "vm_lock_duration_days", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "launchpadMetadata.currencyConfig", + "columnName": "lp_currency_config", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.liquidityPool", + "columnName": "lp_liquidity_pool", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.seed", + "columnName": "lp_seed", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.authority", + "columnName": "lp_authority", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.mintVault", + "columnName": "lp_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.coreMintVault", + "columnName": "lp_core_mint_vault", + "affinity": "TEXT" + }, + { + "fieldPath": "launchpadMetadata.currentCirculatingSupplyQuarks", + "columnName": "lp_circulating_supply_quarks", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.sellFeeBps", + "columnName": "lp_sell_fee_bps", + "affinity": "INTEGER" + }, + { + "fieldPath": "launchpadMetadata.priceAmount", + "columnName": "lp_price_amount_usd", + "affinity": "REAL" + }, + { + "fieldPath": "launchpadMetadata.marketCapAmount", + "columnName": "lp_market_cap_amount_usd", + "affinity": "REAL" + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "address" + ] + } + }, + { + "tableName": "token_social_links", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `token_address` TEXT NOT NULL, `type` TEXT NOT NULL, `value` TEXT NOT NULL, FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "type", + "columnName": "type", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "value", + "columnName": "value", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_token_social_links_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_social_links_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "token_valuation", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`token_address` TEXT NOT NULL, `balance_quarks` INTEGER NOT NULL, `cost_basis` REAL NOT NULL, PRIMARY KEY(`token_address`), FOREIGN KEY(`token_address`) REFERENCES `tokens`(`address`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "tokenAddress", + "columnName": "token_address", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "balanceQuarks", + "columnName": "balance_quarks", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "costBasis", + "columnName": "cost_basis", + "affinity": "REAL", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "token_address" + ] + }, + "indices": [ + { + "name": "index_token_valuation_token_address", + "unique": false, + "columnNames": [ + "token_address" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_token_valuation_token_address` ON `${TABLE_NAME}` (`token_address`)" + } + ], + "foreignKeys": [ + { + "table": "tokens", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "token_address" + ], + "referencedColumns": [ + "address" + ] + } + ] + }, + { + "tableName": "currency_creator_draft", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL, `description` TEXT NOT NULL, `icon_uri` TEXT, `bill_customizations` TEXT, `attestations` TEXT, `current_step` TEXT NOT NULL, `created_mint` TEXT, `saved_at` INTEGER NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "iconUri", + "columnName": "icon_uri", + "affinity": "TEXT" + }, + { + "fieldPath": "billCustomizations", + "columnName": "bill_customizations", + "affinity": "TEXT" + }, + { + "fieldPath": "attestations", + "columnName": "attestations", + "affinity": "TEXT" + }, + { + "fieldPath": "currentStep", + "columnName": "current_step", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "createdMint", + "columnName": "created_mint", + "affinity": "TEXT" + }, + { + "fieldPath": "savedAt", + "columnName": "saved_at", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_sync_state", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `checksumBytes` BLOB NOT NULL, `lastSyncTimestamp` INTEGER NOT NULL, `needsFullUpload` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "checksumBytes", + "columnName": "checksumBytes", + "affinity": "BLOB", + "notNull": true + }, + { + "fieldPath": "lastSyncTimestamp", + "columnName": "lastSyncTimestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "needsFullUpload", + "columnName": "needsFullUpload", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "contact_mapping", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`e164` TEXT NOT NULL, `androidContactId` INTEGER NOT NULL, `displayName` TEXT NOT NULL, `photoUri` TEXT, `isOnFlipcash` INTEGER NOT NULL, PRIMARY KEY(`e164`))", + "fields": [ + { + "fieldPath": "e164", + "columnName": "e164", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "androidContactId", + "columnName": "androidContactId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "displayName", + "columnName": "displayName", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "photoUri", + "columnName": "photoUri", + "affinity": "TEXT" + }, + { + "fieldPath": "isOnFlipcash", + "columnName": "isOnFlipcash", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "e164" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '783158c45e37de0e5559998c10243663')" + ] + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt index 1e97a86da..8edaa1dbe 100644 --- a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/FlipcashDatabase.kt @@ -12,9 +12,12 @@ import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import com.flipcash.app.persistence.converters.TokenTypeConverters +import com.flipcash.app.persistence.dao.ContactDao import com.flipcash.app.persistence.dao.CurrencyCreatorDraftDao import com.flipcash.app.persistence.dao.MessageDao import com.flipcash.app.persistence.dao.TokenDao +import com.flipcash.app.persistence.entities.ContactMappingEntity +import com.flipcash.app.persistence.entities.ContactSyncStateEntity import com.flipcash.app.persistence.entities.CurrencyCreatorDraftEntity import com.flipcash.app.persistence.entities.MessageEntity import com.flipcash.app.persistence.entities.SocialLinkEntity @@ -32,6 +35,8 @@ import com.getcode.utils.subByteArray SocialLinkEntity::class, TokenValuationEntity::class, CurrencyCreatorDraftEntity::class, + ContactSyncStateEntity::class, + ContactMappingEntity::class, ], autoMigrations = [ AutoMigration(from = 1, to = 2, spec = FlipcashDatabase.Migration1To2::class), @@ -48,8 +53,9 @@ import com.getcode.utils.subByteArray AutoMigration(from = 12, to = 13, spec = FlipcashDatabase.Migration12To13::class), AutoMigration(from = 13, to = 14), AutoMigration(from = 14, to = 15), + AutoMigration(from = 15, to = 16), ], - version = 15, + version = 16, ) @TypeConverters(TokenTypeConverters::class) abstract class FlipcashDatabase : RoomDatabase() { @@ -57,6 +63,7 @@ abstract class FlipcashDatabase : RoomDatabase() { abstract fun messageDao(): MessageDao abstract fun tokenDao(): TokenDao abstract fun currencyCreatorDraftDao(): CurrencyCreatorDraftDao + abstract fun contactDao(): ContactDao class Migration1To2 : Migration(1, 2), AutoMigrationSpec { override fun migrate(db: SupportSQLiteDatabase) { 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 new file mode 100644 index 000000000..3175f0199 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/dao/ContactDao.kt @@ -0,0 +1,61 @@ +package com.flipcash.app.persistence.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import com.flipcash.app.persistence.entities.ContactMappingEntity +import com.flipcash.app.persistence.entities.ContactSyncStateEntity +import kotlinx.coroutines.flow.Flow + +@Dao +interface ContactDao { + + // region Sync state + + @Query("SELECT * FROM contact_sync_state WHERE id = 0") + suspend fun getSyncState(): ContactSyncStateEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertSyncState(state: ContactSyncStateEntity) + + @Query("DELETE FROM contact_sync_state") + suspend fun clearSyncState() + + // endregion + + // region Contact mappings + + @Query("SELECT * FROM contact_mapping") + suspend fun getAllMappings(): List + + @Query("SELECT * FROM contact_mapping") + fun observeAllMappings(): Flow> + + @Query("SELECT * FROM contact_mapping WHERE isOnFlipcash = 1") + fun observeFlipcashContacts(): Flow> + + @Query("SELECT * FROM contact_mapping WHERE isOnFlipcash = 0") + fun observeNonFlipcashContacts(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsertMappings(mappings: List) + + @Query("DELETE FROM contact_mapping WHERE e164 IN (:e164s)") + suspend fun deleteMappings(e164s: List) + + @Query("UPDATE contact_mapping SET isOnFlipcash = 0") + suspend fun clearFlipcashStatus() + + @Query("UPDATE contact_mapping SET isOnFlipcash = 1 WHERE e164 IN (:e164s)") + suspend fun markAsFlipcash(e164s: List) + + // endregion + + @Transaction + suspend fun clearAll() { + clearSyncState() + clearFlipcashStatus() + } +} diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt new file mode 100644 index 000000000..f4b62ec4d --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactMappingEntity.kt @@ -0,0 +1,14 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "contact_mapping") +data class ContactMappingEntity( + @PrimaryKey + val e164: String, + val androidContactId: Long, + val displayName: String, + val photoUri: String?, + val isOnFlipcash: Boolean = false, +) diff --git a/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactSyncStateEntity.kt b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactSyncStateEntity.kt new file mode 100644 index 000000000..321232bc2 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/main/kotlin/com/flipcash/app/persistence/entities/ContactSyncStateEntity.kt @@ -0,0 +1,30 @@ +package com.flipcash.app.persistence.entities + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "contact_sync_state") +data class ContactSyncStateEntity( + @PrimaryKey + val id: Int = 0, + val checksumBytes: ByteArray, + val lastSyncTimestamp: Long, + val needsFullUpload: Boolean = false, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ContactSyncStateEntity) return false + return id == other.id && + checksumBytes.contentEquals(other.checksumBytes) && + lastSyncTimestamp == other.lastSyncTimestamp && + needsFullUpload == other.needsFullUpload + } + + override fun hashCode(): Int { + var result = id + result = 31 * result + checksumBytes.contentHashCode() + result = 31 * result + lastSyncTimestamp.hashCode() + result = 31 * result + needsFullUpload.hashCode() + return result + } +} diff --git a/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ContactDaoTest.kt b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ContactDaoTest.kt new file mode 100644 index 000000000..cf45a76c0 --- /dev/null +++ b/apps/flipcash/shared/persistence/db/src/test/kotlin/com/flipcash/app/persistence/dao/ContactDaoTest.kt @@ -0,0 +1,238 @@ +package com.flipcash.app.persistence.dao + +import android.content.Context +import androidx.room.Room +import androidx.test.core.app.ApplicationProvider +import app.cash.turbine.test +import com.flipcash.app.persistence.FlipcashDatabase +import com.flipcash.app.persistence.entities.ContactMappingEntity +import com.flipcash.app.persistence.entities.ContactSyncStateEntity +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertNull +import kotlin.test.assertTrue + +@RunWith(RobolectricTestRunner::class) +class ContactDaoTest { + + private lateinit var db: FlipcashDatabase + private lateinit var dao: ContactDao + + @Before + fun setUp() { + val context = ApplicationProvider.getApplicationContext() + db = Room.inMemoryDatabaseBuilder(context, FlipcashDatabase::class.java) + .allowMainThreadQueries() + .build() + dao = db.contactDao() + } + + @After + fun tearDown() { + db.close() + } + + // -- helpers -- + + private fun makeMapping( + e164: String = "+1555${System.nanoTime() % 10_000_000}", + contactId: Long = 1L, + displayName: String = "Test User", + photoUri: String? = null, + isOnFlipcash: Boolean = false, + ) = ContactMappingEntity( + e164 = e164, + androidContactId = contactId, + displayName = displayName, + photoUri = photoUri, + isOnFlipcash = isOnFlipcash, + ) + + private fun makeSyncState( + checksumBytes: ByteArray = ByteArray(32) { it.toByte() }, + timestamp: Long = System.currentTimeMillis(), + needsFullUpload: Boolean = false, + ) = ContactSyncStateEntity( + checksumBytes = checksumBytes, + lastSyncTimestamp = timestamp, + needsFullUpload = needsFullUpload, + ) + + // -- Sync state tests -- + + @Test + fun `getSyncState returns null when empty`() = runTest { + assertNull(dao.getSyncState()) + } + + @Test + fun `upsertSyncState and getSyncState roundtrip`() = runTest { + val state = makeSyncState(timestamp = 12345L) + dao.upsertSyncState(state) + + val result = dao.getSyncState() + assertNotNull(result) + assertEquals(12345L, result.lastSyncTimestamp) + assertTrue(result.checksumBytes.contentEquals(state.checksumBytes)) + } + + @Test + fun `upsertSyncState replaces existing`() = runTest { + dao.upsertSyncState(makeSyncState(timestamp = 100L)) + dao.upsertSyncState(makeSyncState(timestamp = 200L)) + + val result = dao.getSyncState() + assertNotNull(result) + assertEquals(200L, result.lastSyncTimestamp) + } + + @Test + fun `clearSyncState removes sync state`() = runTest { + dao.upsertSyncState(makeSyncState()) + assertNotNull(dao.getSyncState()) + + dao.clearSyncState() + assertNull(dao.getSyncState()) + } + + // -- Contact mapping tests -- + + @Test + fun `upsertMappings and getAllMappings roundtrip`() = runTest { + val m1 = makeMapping(e164 = "+15551111111", displayName = "Alice") + val m2 = makeMapping(e164 = "+15552222222", displayName = "Bob") + dao.upsertMappings(listOf(m1, m2)) + + val all = dao.getAllMappings() + assertEquals(2, all.size) + assertTrue(all.any { it.displayName == "Alice" }) + assertTrue(all.any { it.displayName == "Bob" }) + } + + @Test + fun `upsertMappings replaces on conflict`() = runTest { + dao.upsertMappings(listOf(makeMapping(e164 = "+15551111111", displayName = "Old"))) + dao.upsertMappings(listOf(makeMapping(e164 = "+15551111111", displayName = "New"))) + + val all = dao.getAllMappings() + assertEquals(1, all.size) + assertEquals("New", all.first().displayName) + } + + @Test + fun `deleteMappings removes specified entries`() = runTest { + dao.upsertMappings(listOf( + makeMapping(e164 = "+15551111111"), + makeMapping(e164 = "+15552222222"), + makeMapping(e164 = "+15553333333"), + )) + assertEquals(3, dao.getAllMappings().size) + + dao.deleteMappings(listOf("+15551111111", "+15553333333")) + val remaining = dao.getAllMappings() + assertEquals(1, remaining.size) + assertEquals("+15552222222", remaining.first().e164) + } + + // -- Flipcash status tests -- + + @Test + fun `markAsFlipcash updates specified entries`() = runTest { + dao.upsertMappings(listOf( + makeMapping(e164 = "+15551111111"), + makeMapping(e164 = "+15552222222"), + makeMapping(e164 = "+15553333333"), + )) + + dao.markAsFlipcash(listOf("+15551111111", "+15553333333")) + + val all = dao.getAllMappings() + assertTrue(all.first { it.e164 == "+15551111111" }.isOnFlipcash) + assertTrue(!all.first { it.e164 == "+15552222222" }.isOnFlipcash) + assertTrue(all.first { it.e164 == "+15553333333" }.isOnFlipcash) + } + + @Test + fun `clearFlipcashStatus resets all to false`() = runTest { + dao.upsertMappings(listOf( + makeMapping(e164 = "+15551111111", isOnFlipcash = true), + makeMapping(e164 = "+15552222222", isOnFlipcash = true), + )) + + dao.clearFlipcashStatus() + + val all = dao.getAllMappings() + assertTrue(all.none { it.isOnFlipcash }) + } + + // -- Flow observation tests -- + + @Test + fun `observeFlipcashContacts emits only Flipcash contacts`() = runTest { + dao.upsertMappings(listOf( + makeMapping(e164 = "+15551111111", isOnFlipcash = true), + makeMapping(e164 = "+15552222222", isOnFlipcash = false), + )) + + dao.observeFlipcashContacts().test { + val result = awaitItem() + assertEquals(1, result.size) + assertEquals("+15551111111", result.first().e164) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeNonFlipcashContacts emits only non-Flipcash contacts`() = runTest { + dao.upsertMappings(listOf( + makeMapping(e164 = "+15551111111", isOnFlipcash = true), + makeMapping(e164 = "+15552222222", isOnFlipcash = false), + )) + + dao.observeNonFlipcashContacts().test { + val result = awaitItem() + assertEquals(1, result.size) + assertEquals("+15552222222", result.first().e164) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `observeAllMappings emits on changes`() = runTest { + dao.observeAllMappings().test { + assertEquals(emptyList(), awaitItem()) + + dao.upsertMappings(listOf(makeMapping(e164 = "+15551111111"))) + assertEquals(1, awaitItem().size) + + dao.upsertMappings(listOf(makeMapping(e164 = "+15552222222"))) + assertEquals(2, awaitItem().size) + + cancelAndIgnoreRemainingEvents() + } + } + + // -- clearAll tests -- + + @Test + fun `clearAll removes sync state and resets flipcash status`() = runTest { + dao.upsertSyncState(makeSyncState()) + dao.upsertMappings(listOf( + makeMapping(e164 = "+15551111111", isOnFlipcash = true), + )) + + dao.clearAll() + + assertNull(dao.getSyncState()) + // Mappings still exist but flipcash status is cleared + val mappings = dao.getAllMappings() + assertEquals(1, mappings.size) + assertTrue(!mappings.first().isOnFlipcash) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 78b196dc6..1c225aba7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -49,6 +49,7 @@ include( ":apps:flipcash:shared:activityfeed", ":apps:flipcash:shared:bills", ":apps:flipcash:shared:bill-customization", + ":apps:flipcash:shared:contacts", ":apps:flipcash:shared:currency-creator", ":apps:flipcash:shared:onramp:coinbase", ":apps:flipcash:shared:onramp:deeplinks", From 26659e2b0593a6a2ddbcc3249a92bb4cddfb8114 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Tue, 26 May 2026 22:11:41 -0400 Subject: [PATCH 3/3] feat(contacts): reset contact state on logout via SessionController --- apps/flipcash/shared/session/build.gradle.kts | 1 + .../com/flipcash/app/session/internal/RealSessionController.kt | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/flipcash/shared/session/build.gradle.kts b/apps/flipcash/shared/session/build.gradle.kts index 19f99caa2..f4aca86af 100644 --- a/apps/flipcash/shared/session/build.gradle.kts +++ b/apps/flipcash/shared/session/build.gradle.kts @@ -11,6 +11,7 @@ dependencies { testImplementation(libs.bundles.unit.testing) testImplementation(project(":libs:test-utils")) + implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":apps:flipcash:shared:activityfeed")) implementation(project(":apps:flipcash:shared:analytics")) implementation(project(":apps:flipcash:shared:appsettings")) diff --git a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt index ef8153ada..228d127ae 100644 --- a/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt +++ b/apps/flipcash/shared/session/src/main/kotlin/com/flipcash/app/session/internal/RealSessionController.kt @@ -7,6 +7,7 @@ import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.appsettings.AppSettingValue import com.flipcash.app.appsettings.AppSettingsCoordinator import com.flipcash.app.billing.BillingClient +import com.flipcash.app.contacts.ContactCoordinator import com.flipcash.app.core.bill.Bill import com.flipcash.app.core.bill.BillState import com.flipcash.app.core.bill.PaymentValuation @@ -122,6 +123,7 @@ class RealSessionController @Inject constructor( private val toastController: ToastController, private val billingClient: BillingClient, private val tokenCoordinator: TokenCoordinator, + private val contactCoordinator: ContactCoordinator, private val featureFlagController: FeatureFlagController, private val analytics: FlipcashAnalyticsService, private val usdcSweep: UsdcDepositSweep, @@ -152,6 +154,7 @@ class RealSessionController @Inject constructor( authState is AuthState.LoggedOut -> { stopPolling() cancelUpdates() + scope.launch { contactCoordinator.reset() } _state.update { SessionState() } } authState.isAtLeastRegistered -> {