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/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt index 8b8acc655..7b23e8b09 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/internal/ui/navigation/AppScreenContent.kt @@ -40,6 +40,7 @@ import com.flipcash.app.login.router.LoginRouter import com.flipcash.app.login.seed.SeedInputScreen import com.flipcash.app.menu.MenuScreen import com.flipcash.app.myaccount.MyAccountScreen +import com.flipcash.app.permissions.ContactPermissionScreen import com.flipcash.app.permissions.NotificationPermissionRationaleScreen import com.flipcash.app.permissions.NotificationPermissionScreen import com.flipcash.app.purchase.PurchaseAccountScreen @@ -80,6 +81,7 @@ fun appEntryProvider( annotatedEntry { AccessKeyScreen() } annotatedEntry { PhotoAccessKeyScreen() } annotatedEntry { key -> PurchaseAccountScreen(key.fromLogin) } + annotatedEntry { key -> ContactPermissionScreen(key.postCreate) } annotatedEntry { key -> NotificationPermissionScreen(key.postCreate) } annotatedEntry { key -> NotificationPermissionRationaleScreen(key.permanentlyDenied) } annotatedEntry { } 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/core/src/main/kotlin/com/flipcash/app/core/ui/DeviceFrame.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/DeviceFrame.kt index 6e08c811d..7dcc41100 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/DeviceFrame.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/ui/DeviceFrame.kt @@ -12,6 +12,28 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.unit.dp import com.flipcash.core.R +@Composable +fun ScreenFrame( + modifier: Modifier = Modifier, + contentAlignment: Alignment = Alignment.TopCenter, + contents: @Composable () -> Unit, +) { + Box(modifier = modifier, contentAlignment = Alignment.TopCenter) { + Image( + painter = painterResource(id = R.drawable.ic_screen_frame), + contentDescription = "", + ) + Box( + modifier = Modifier + .matchParentSize() + .clip(RoundedCornerShape(35.dp)), + contentAlignment = contentAlignment, + ) { + contents() + } + } +} + @Composable fun DeviceFrame( modifier: Modifier = Modifier, diff --git a/apps/flipcash/core/src/main/res/drawable/ic_screen_frame.xml b/apps/flipcash/core/src/main/res/drawable/ic_screen_frame.xml new file mode 100644 index 000000000..2f0b13b7f --- /dev/null +++ b/apps/flipcash/core/src/main/res/drawable/ic_screen_frame.xml @@ -0,0 +1,36 @@ + + + + + + + + + + + diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index 00f283c73..2282dab20 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -92,6 +92,13 @@ You won\'t receive updates when your balance changes Ok Allow I\'m Sure + Not Now + + Find Your Friends + Sync your contacts to find,\ninvite, and pay friends. + Give Access To Contacts + Are You Sure? + You won\'t be able to send cash to your contacts Start your camera to grab cash Start Camera 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 = { diff --git a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt index b34fad121..edd8626ba 100644 --- a/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt +++ b/apps/flipcash/features/lab/src/main/kotlin/com/flipcash/app/lab/internal/LabsScreenContent.kt @@ -89,16 +89,6 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { ) } - item { SectionHeader(stringResource(R.string.title_settingsSectionHomeScreen)) } - item { - ListItem( - headline = stringResource(R.string.title_settingsButtonOrder), - icon = painterResource(R.drawable.ic_bottom_navigation), - ) { - navigator.navigate(AppRoute.Menu.NavBarSettings) - } - } - if (betaFlags.isEmpty()) { item { Box { @@ -126,6 +116,16 @@ internal fun LabsScreenContent(viewModel: LabsScreenViewModel) { } } + item { SectionHeader(stringResource(R.string.title_settingsSectionHomeScreen)) } + item { + ListItem( + headline = stringResource(R.string.title_settingsButtonOrder), + icon = painterResource(R.drawable.ic_bottom_navigation), + ) { + navigator.navigate(AppRoute.Menu.NavBarSettings) + } + } + if (isStaff) { item { SectionHeader(stringResource(R.string.title_settingsSectionDeveloper)) } item { diff --git a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Actions.kt b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Actions.kt index 8a8580133..44c79ded7 100644 --- a/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Actions.kt +++ b/apps/flipcash/shared/analytics/src/main/kotlin/com/flipcash/app/analytics/Actions.kt @@ -33,6 +33,14 @@ sealed interface Button: AppAction { override val value: String = "Button: Skip Push" } + data object AllowContacts : Button { + override val value: String = "Button: Allow Contacts" + } + + data object SkipContacts: Button { + override val value: String = "Button: Skip Contacts" + } + data object TokenBuyWithReserves : Button { override val value: String = "Button: Buy With Reserves" } 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/permissions/build.gradle.kts b/apps/flipcash/shared/permissions/build.gradle.kts index dfbafc085..87391fce1 100644 --- a/apps/flipcash/shared/permissions/build.gradle.kts +++ b/apps/flipcash/shared/permissions/build.gradle.kts @@ -8,6 +8,7 @@ android { dependencies { implementation(project(":apps:flipcash:shared:analytics")) + implementation(project(":apps:flipcash:shared:contacts")) implementation(project(":libs:datetime")) implementation(project(":libs:messaging")) implementation(project(":libs:permissions:bindings")) diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactPermissionScreen.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactPermissionScreen.kt new file mode 100644 index 000000000..8f47c095b --- /dev/null +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/ContactPermissionScreen.kt @@ -0,0 +1,72 @@ +package com.flipcash.app.permissions + +import androidx.activity.compose.BackHandler +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import com.flipcash.app.analytics.Action +import com.flipcash.app.analytics.Button +import com.flipcash.app.core.AppRoute +import com.flipcash.app.permissions.internal.contacts.ContactScreenContent +import com.getcode.libs.analytics.LocalAnalytics +import com.getcode.navigation.core.LocalCodeNavigator +import com.getcode.navigation.core.NavOptions +import com.getcode.util.permissions.PermissionResult +import com.getcode.util.permissions.rememberContactPermission + +@Composable +fun ContactPermissionScreen(fromOnboarding: Boolean) { + val navigator = LocalCodeNavigator.current + val analytics = LocalAnalytics.current + + val permissionState = rememberContactPermission { result -> + when (result) { + PermissionResult.Granted -> { + analytics.action(Button.AllowContacts) + if (fromOnboarding) analytics.action(Action.CompletedOnboarding) + navigator.push( + AppRoute.Onboarding.NotificationPermission(fromOnboarding) + ) + } + PermissionResult.Denied -> { + navigator.push( + AppRoute.Onboarding.NotificationPermission(fromOnboarding) + ) + } + PermissionResult.PermanentlyDenied -> { + navigator.push( + AppRoute.Onboarding.NotificationPermission(fromOnboarding) + ) + } + PermissionResult.NotRequested -> Unit + } + } + + LaunchedEffect(Unit) { + when (permissionState.status) { + PermissionResult.Granted -> navigator.navigate( + route = AppRoute.Main.Scanner, + options = NavOptions(popUpTo = NavOptions.PopUpTo.ClearAll) + ) + PermissionResult.PermanentlyDenied -> navigator.push( + AppRoute.Onboarding.NotificationPermissionRationale(true) + ) + // NotRequested + Denied both render screen 1 + // Denied = show rationale (screen 1) then re-trigger dialog on OK + PermissionResult.NotRequested, + PermissionResult.Denied -> Unit + } + } + + // Only reached when status is NotRequested + ContactScreenContent( + permissionState = permissionState, + onSkip = { + analytics.action(Button.SkipContacts) + navigator.push( + AppRoute.Onboarding.NotificationPermission(fromOnboarding) + ) + } + ) + + BackHandler(fromOnboarding) { } +} \ No newline at end of file diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt index cf6ec3e5a..dfe77a123 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/NotificationPermissionScreen.kt @@ -6,8 +6,8 @@ import androidx.compose.runtime.LaunchedEffect import com.flipcash.app.analytics.Action import com.flipcash.app.analytics.Button import com.flipcash.app.core.AppRoute -import com.flipcash.app.permissions.internal.NotificationRationalePermissionContent -import com.flipcash.app.permissions.internal.NotificationScreenContent +import com.flipcash.app.permissions.internal.notifications.NotificationRationalePermissionContent +import com.flipcash.app.permissions.internal.notifications.NotificationScreenContent import com.getcode.libs.analytics.LocalAnalytics import com.getcode.navigation.core.LocalCodeNavigator import com.getcode.navigation.core.NavOptions diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt new file mode 100644 index 000000000..6df5c5356 --- /dev/null +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/ContactPermissionScreenContent.kt @@ -0,0 +1,78 @@ +package com.flipcash.app.permissions.internal.contacts + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview +import com.flipcash.app.analytics.StubFlipcashAnalytics +import com.flipcash.app.permissions.internal.contacts.components.AnimatedContactListPreview +import com.flipcash.app.permissions.internal.contacts.components.ContactPermissionBottomBar +import com.flipcash.app.theme.FlipcashPreview +import com.flipcash.shared.permissions.R +import com.getcode.libs.analytics.LocalAnalytics +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.CodeScaffold +import com.getcode.util.permissions.PermissionHandle +import com.getcode.util.permissions.ProvideTestPermissions +import com.getcode.util.permissions.rememberContactPermission + +@Composable +internal fun ContactScreenContent( + permissionState: PermissionHandle, + onSkip: () -> Unit, +) { + CodeScaffold( + bottomBar = { + ContactPermissionBottomBar( + permission = permissionState, + onSkip = onSkip, + ) + } + ) { + Box(Modifier.fillMaxSize()) { + Column( + modifier = Modifier.align(Alignment.Center), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + AnimatedContactListPreview(animate = false) + + Text( + text = stringResource(R.string.permissions_title_contacts), + style = CodeTheme.typography.displaySmall, + color = CodeTheme.colors.textMain, + ) + Text( + modifier = Modifier + .fillMaxWidth(0.8f) + .padding(horizontal = CodeTheme.dimens.inset), + text = stringResource(R.string.permissions_description_contacts), + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + textAlign = TextAlign.Center, + ) + } + } + } +} + +@Composable +@Preview +private fun PreviewContactPermissionScreen() { + FlipcashPreview(showBackground = true) { + CompositionLocalProvider(LocalAnalytics provides StubFlipcashAnalytics()) { + ProvideTestPermissions(granted = emptySet()) { + val state = rememberContactPermission() + ContactScreenContent(state) { } + } + } + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/AnimatedContactListPreview.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/AnimatedContactListPreview.kt new file mode 100644 index 000000000..8cf1bb2c9 --- /dev/null +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/AnimatedContactListPreview.kt @@ -0,0 +1,126 @@ +package com.flipcash.app.permissions.internal.contacts.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.wrapContentSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.CompositingStrategy +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.unit.dp +import com.flipcash.app.core.ui.ScreenFrame +import com.getcode.theme.CodeTheme +import com.getcode.theme.White20 +import kotlinx.coroutines.delay + +private data class ContactRow(val nameFraction: Float, val subtitleFraction: Float) + +private val contactRows = listOf( + ContactRow(0.60f, 0.50f), + ContactRow(0.45f, 0.40f), + ContactRow(0.70f, 0.55f), + ContactRow(0.40f, 0.35f), + ContactRow(0.55f, 0.45f), + ContactRow(0.35f, 0.30f), +) + +@Composable +private fun ContactRowPlaceholder(row: ContactRow) { + val barShape = RoundedCornerShape(CodeTheme.dimens.grid.x1) + Row( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.grid.x5), + verticalAlignment = Alignment.CenterVertically, + ) { + Box( + modifier = Modifier + .size(CodeTheme.dimens.staticGrid.x7) + .background(White20, CircleShape), + ) + Spacer(modifier = Modifier.width(CodeTheme.dimens.grid.x2)) + Column( + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) + ) { + Box( + modifier = Modifier + .fillMaxWidth(row.nameFraction) + .height(CodeTheme.dimens.grid.x2) + .background(White20, barShape), + ) + Box( + modifier = Modifier + .fillMaxWidth(row.subtitleFraction) + .height(CodeTheme.dimens.grid.x2) + .background(White20, barShape), + ) + } + } +} + +private val bottomFadeBrush = Brush.verticalGradient( + 0f to Color.Black, + 0.4f to Color.Black, + 0.85f to Color.Transparent, +) + +@Composable +internal fun AnimatedContactListPreview(animate: Boolean = true) { + val alpha = remember { Animatable(if (animate) 0f else 1f) } + + LaunchedEffect(Unit) { + if (animate) { + delay(300) + alpha.animateTo(1f, tween(600)) + } + } + + Box( + modifier = Modifier + .height(340.dp) + .graphicsLayer { + this.alpha = alpha.value + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + drawRect(brush = bottomFadeBrush, blendMode = BlendMode.DstIn) + }, + ) { + ScreenFrame( + modifier = Modifier.wrapContentSize(unbounded = true, align = Alignment.TopCenter), + ) { + Column( + modifier = Modifier + .fillMaxSize() + .padding(top = CodeTheme.dimens.inset), + verticalArrangement = Arrangement.spacedBy(CodeTheme.dimens.inset), + ) { + contactRows.forEach { row -> + ContactRowPlaceholder(row = row) + } + } + } + } +} diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/ContactPermissionBottomBar.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/ContactPermissionBottomBar.kt new file mode 100644 index 000000000..c20d4b642 --- /dev/null +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/contacts/components/ContactPermissionBottomBar.kt @@ -0,0 +1,69 @@ +package com.flipcash.app.permissions.internal.contacts.components + +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import com.flipcash.shared.permissions.R +import com.getcode.manager.BottomBarAction +import com.getcode.manager.BottomBarManager +import com.getcode.theme.CodeTheme +import com.getcode.ui.theme.ButtonState +import com.getcode.ui.theme.CodeButton +import com.getcode.util.permissions.PermissionHandle +import com.getcode.util.resources.LocalResources + +@Composable +internal fun ContactPermissionBottomBar( + permission: PermissionHandle, + onSkip: () -> Unit, +) { + val resources = LocalResources.current + Column( + modifier = Modifier.fillMaxWidth() + .navigationBarsPadding() + .padding(bottom = CodeTheme.dimens.grid.x3), + horizontalAlignment = Alignment.CenterHorizontally + ) { + CodeButton( + onClick = { permission.launch() }, + text = stringResource(R.string.action_giveAccessToContacts), + buttonState = ButtonState.Filled, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = CodeTheme.dimens.inset), + ) + + CodeButton( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = CodeTheme.dimens.grid.x2) + .padding(horizontal = CodeTheme.dimens.inset), + onClick = { + BottomBarManager.showAlert( + title = resources.getString(R.string.error_title_ignoredContactPermissions), + message = resources.getString(R.string.error_description_ignoredContactPermissions), + actions = listOf( + BottomBarAction( + text = resources.getString(R.string.action_okAllow) + ) { + permission.launch() + }, + BottomBarAction( + text = resources.getString(R.string.action_imSure), + style = BottomBarManager.BottomBarButtonStyle.Text + ) { + onSkip() + } + ) + ) + }, + text = stringResource(R.string.action_notNow), + buttonState = ButtonState.Subtle, + ) + } +} \ No newline at end of file diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/NotificationPermissionScreenContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt similarity index 91% rename from apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/NotificationPermissionScreenContent.kt rename to apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt index 08bc69a84..1b86e5114 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/NotificationPermissionScreenContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationPermissionScreenContent.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.permissions.internal +package com.flipcash.app.permissions.internal.notifications import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -14,8 +14,8 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview import com.flipcash.app.analytics.StubFlipcashAnalytics -import com.flipcash.app.permissions.internal.components.AnimatedNotificationPreview -import com.flipcash.app.permissions.internal.components.NotificationPermissionBottomBar +import com.flipcash.app.permissions.internal.notifications.components.AnimatedNotificationPreview +import com.flipcash.app.permissions.internal.notifications.components.NotificationPermissionBottomBar import com.flipcash.app.theme.FlipcashPreview import com.flipcash.shared.permissions.R import com.getcode.libs.analytics.LocalAnalytics diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/NotificationRationalePermissionContent.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt similarity index 96% rename from apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/NotificationRationalePermissionContent.kt rename to apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt index 39c5765ca..7072c2cb3 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/NotificationRationalePermissionContent.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/NotificationRationalePermissionContent.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.permissions.internal +package com.flipcash.app.permissions.internal.notifications import androidx.activity.compose.BackHandler import androidx.compose.foundation.layout.Arrangement @@ -8,7 +8,7 @@ import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding -import androidx.compose.material.Text +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.Alignment @@ -22,7 +22,7 @@ import com.flipcash.app.analytics.StubFlipcashAnalytics import com.flipcash.app.analytics.rememberAnalytics import com.flipcash.app.core.AppRoute import com.flipcash.app.core.android.extensions.launchAppSettings -import com.flipcash.app.permissions.internal.components.AnimatedSwitchPreview +import com.flipcash.app.permissions.internal.notifications.components.AnimatedSwitchPreview import com.flipcash.app.theme.FlipcashPreview import com.flipcash.shared.permissions.R import com.getcode.libs.analytics.LocalAnalytics diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/components/AnimatedNotificationPreview.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/components/AnimatedNotificationPreview.kt similarity index 96% rename from apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/components/AnimatedNotificationPreview.kt rename to apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/components/AnimatedNotificationPreview.kt index 575177add..24a127ab8 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/components/AnimatedNotificationPreview.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/components/AnimatedNotificationPreview.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.permissions.internal.components +package com.flipcash.app.permissions.internal.notifications.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/components/AnimatedSwitchControl.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/components/AnimatedSwitchControl.kt similarity index 94% rename from apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/components/AnimatedSwitchControl.kt rename to apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/components/AnimatedSwitchControl.kt index 54b4a583d..aea643a7d 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/components/AnimatedSwitchControl.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/components/AnimatedSwitchControl.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.permissions.internal.components +package com.flipcash.app.permissions.internal.notifications.components import androidx.compose.animation.core.Animatable import androidx.compose.animation.core.tween @@ -54,7 +54,7 @@ private fun rememberSwitchAnimation(animate: Boolean = true, checked: Boolean = } @Composable -internal fun AnimatedSwitchPreview(animate: Boolean = false) { +internal fun AnimatedSwitchPreview(animate: Boolean = true) { val animation = rememberSwitchAnimation(animate) DeviceFrame( diff --git a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/components/NotificationPermissionBottomBar.kt b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/components/NotificationPermissionBottomBar.kt similarity index 97% rename from apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/components/NotificationPermissionBottomBar.kt rename to apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/components/NotificationPermissionBottomBar.kt index 76cb7b10f..6c2832b0a 100644 --- a/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/components/NotificationPermissionBottomBar.kt +++ b/apps/flipcash/shared/permissions/src/main/kotlin/com/flipcash/app/permissions/internal/notifications/components/NotificationPermissionBottomBar.kt @@ -1,4 +1,4 @@ -package com.flipcash.app.permissions.internal.components +package com.flipcash.app.permissions.internal.notifications.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxWidth 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/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 -> { diff --git a/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionConfig.kt b/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionConfig.kt index e55f07916..87ed49412 100644 --- a/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionConfig.kt +++ b/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/PermissionConfig.kt @@ -68,6 +68,19 @@ object PermissionConfigs { } } + /** + * Config for [Manifest.permission.READ_CONTACTS]. + * Requires a runtime request on all supported API levels. + */ + @Composable + fun contacts(): PermissionConfig { + return remember { + PermissionConfig( + permission = Manifest.permission.READ_CONTACTS, + ) + } + } + /** * Config for [Manifest.permission.WRITE_EXTERNAL_STORAGE]. * Requires a runtime request on all supported API levels. diff --git a/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/Permissions.kt b/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/Permissions.kt index fe915864c..bf2177d77 100644 --- a/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/Permissions.kt +++ b/libs/permissions/public/src/main/kotlin/com/getcode/util/permissions/Permissions.kt @@ -173,6 +173,17 @@ fun rememberNotificationPermission( onResult: (PermissionResult) -> Unit = {}, ) = rememberPermission(PermissionConfigs.notifications(), onResult) +/** + * Convenience wrapper for [android.Manifest.permission.READ_CONTACTS]. + * + * @see rememberPermission + * @see PermissionConfigs.contacts + */ +@Composable +fun rememberContactPermission( + onResult: (PermissionResult) -> Unit = {}, +) = rememberPermission(PermissionConfigs.contacts(), onResult) + /** * Convenience wrapper for [android.Manifest.permission.CAMERA]. * diff --git a/services/opencode/src/main/kotlin/com/getcode/opencode/providers/SessionListener.kt b/services/opencode/src/main/kotlin/com/getcode/opencode/providers/SessionListener.kt index 74961cdfe..f4a4330b8 100644 --- a/services/opencode/src/main/kotlin/com/getcode/opencode/providers/SessionListener.kt +++ b/services/opencode/src/main/kotlin/com/getcode/opencode/providers/SessionListener.kt @@ -17,5 +17,5 @@ import com.getcode.opencode.model.accounts.AccountCluster */ interface SessionListener { suspend fun onUserLoggedIn(cluster: AccountCluster) - suspend fun onBalanceUpdateRequested() + suspend fun onBalanceUpdateRequested() = Unit } \ No newline at end of file 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",