Skip to content

Commit e77e9ae

Browse files
committed
feat(contacts): suspend contact sync, Android 17 multi-pick picker, and loading button states
Make ContactCoordinator.sync() and addPickedContacts() suspend returning Result<Unit> so callers can await completion. Add ContactAccessHandle using ContactsPickerSessionContract on API 37+ with phone-only filtering, falling back to full permission on older APIs. Thread isLoading/isSuccess through ContactPermissionBottomBar for visual feedback during sync.
1 parent c903b6a commit e77e9ae

13 files changed

Lines changed: 339 additions & 163 deletions

File tree

apps/flipcash/app/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -185,6 +185,7 @@ dependencies {
185185
implementation(project(":apps:flipcash:features:transactions"))
186186
implementation(project(":apps:flipcash:features:bill-customization"))
187187
implementation(project(":apps:flipcash:features:currency-creator"))
188+
implementation(project(":apps:flipcash:features:direct-send"))
188189
implementation(project(":apps:flipcash:features:discovery"))
189190
implementation(project(":apps:flipcash:features:userflags"))
190191

apps/flipcash/core/src/main/res/values/strings.xml

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -275,12 +275,16 @@
275275
<string name="error_description_deeplinkOnRampInsufficientUsdc">Your %1$s wallet doesn\'t have enough USDC for this purchase. Please add more USDC and try again.</string>
276276
<string name="error_description_deeplinkOnRampUnknown">Please try again</string>
277277

278-
<string name="title_verifyPhoneNumber">Verify Phone Number</string>
278+
<string name="title_connectPhoneNumber">Connect Phone Number</string>
279279
<string name="subtitle_enterPhoneNumberToContinue">Please enter your phone number to continue</string>
280280
<string name="subtitle_enterPhoneVerificationCode">An SMS message was sent to your phone number with a verification code.\nPlease enter the verification code above</string>
281281
<string name="subtitle_enterEmailToContinue">Please enter your email to continue</string>
282282

283-
<string name="title_verifyEmailAddress">Verify Your Email</string>
283+
<string name="title_connectPhoneToSend">Connect Phone To Send</string>
284+
<string name="subtitle_connectPhoneToSend">Connect your phone number to send cash.</string>
285+
<string name="action_connectPhoneNumber">Connect Your Phone Number</string>
286+
287+
<string name="title_connectEmailAddress">Connect Your Email</string>
284288
<string name="subtitle_resend">Resend</string>
285289

286290
<string name="title_enterTheCode">Enter The Code</string>

apps/flipcash/features/myaccount/src/main/kotlin/com/flipcash/app/myaccount/internal/MyAccountMenuItems.kt

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,15 +25,15 @@ internal data object VerifyEmail : FullMenuItem<MyAccountScreenViewModel.Event>(
2525
override val icon: Painter
2626
@Composable get() = rememberVectorPainter(Icons.Default.Email)
2727
override val name: String
28-
@Composable get() = stringResource(R.string.title_verifyEmailAddress)
28+
@Composable get() = stringResource(R.string.title_connectEmailAddress)
2929
override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnVerifyEmailClicked
3030
}
3131

3232
internal data object VerifyPhone : FullMenuItem<MyAccountScreenViewModel.Event>() {
3333
override val icon: Painter
3434
@Composable get() = rememberVectorPainter(Icons.Default.Phone)
3535
override val name: String
36-
@Composable get() = stringResource(R.string.title_verifyPhoneNumber)
36+
@Composable get() = stringResource(R.string.title_connectPhoneNumber)
3737
override val action: MyAccountScreenViewModel.Event = MyAccountScreenViewModel.Event.OnVerifyPhoneClicked
3838
}
3939

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

Lines changed: 17 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import androidx.lifecycle.DefaultLifecycleObserver
77
import androidx.lifecycle.LifecycleOwner
88
import androidx.lifecycle.ProcessLifecycleOwner
99
import com.flipcash.app.contacts.device.DeviceContact
10+
import com.flipcash.app.contacts.device.PickedContactData
1011
import com.flipcash.app.contacts.device.ScopeAwareContactReader
1112
import com.flipcash.app.contacts.sync.ContactChecksum
1213
import com.flipcash.app.persistence.FlipcashDatabase
@@ -79,7 +80,7 @@ class ContactCoordinator @Inject constructor(
7980
trace(tag = TAG, message = "User logged in, hydrating contacts", type = TraceType.User)
8081
this.cluster.value = cluster
8182
hydrateFromPersistence()
82-
sync()
83+
launchSync()
8384
}
8485

8586
// endregion
@@ -95,15 +96,15 @@ class ContactCoordinator @Inject constructor(
9596
.filter { it.connected }
9697
.onEach {
9798
trace(tag = TAG, message = "Network connected, triggering contact sync", type = TraceType.Process)
98-
sync()
99+
launchSync()
99100
}
100101
.launchIn(scope)
101102
}
102103

103104
override fun onStart(owner: LifecycleOwner) {
104105
if (cluster.value != null) {
105106
trace(tag = TAG, message = "Lifecycle resumed, triggering contact sync", type = TraceType.Process)
106-
sync()
107+
launchSync()
107108
}
108109
}
109110

@@ -115,14 +116,16 @@ class ContactCoordinator @Inject constructor(
115116

116117
// region Public API
117118

118-
fun sync() {
119+
suspend fun sync(): Result<Unit> = performSync()
120+
121+
private fun launchSync() {
119122
syncJob?.cancel()
120123
syncJob = scope.launch { performSync() }
121124
}
122125

123-
fun addPickedContacts(contactIds: List<Long>) {
124-
contactReader.addSelectedContacts(contactIds)
125-
sync()
126+
suspend fun addPickedContacts(contacts: List<PickedContactData>): Result<Unit> {
127+
contactReader.addSelectedContacts(contacts)
128+
return performSync()
126129
}
127130

128131
suspend fun resolve(e164: String): Result<PublicKey> {
@@ -165,22 +168,22 @@ class ContactCoordinator @Inject constructor(
165168
trace(tag = TAG, message = "Hydrated ${mappings.size} contacts from persistence", type = TraceType.Process)
166169
}
167170

168-
private suspend fun performSync() {
169-
if (cluster.value == null) return
171+
private suspend fun performSync(): Result<Unit> {
172+
if (cluster.value == null) return Result.failure(IllegalStateException("No active session"))
170173

171174
_state.update { it.copy(syncState = SyncState.Syncing) }
172175

173176
try {
174177
// 1. Read device contacts
175178
val deviceContacts = contactReader.readAll().getOrElse { error ->
176179
trace(tag = TAG, message = "Cannot read contacts: ${error.message}", type = TraceType.Log)
177-
return
180+
return Result.failure(error)
178181
}
179182

180183
if (deviceContacts.isEmpty()) {
181184
trace(tag = TAG, message = "No device contacts found", type = TraceType.Process)
182185
_state.update { it.copy(syncState = SyncState.Synced) }
183-
return
186+
return Result.success(Unit)
184187
}
185188

186189
// 2. Compute checksum
@@ -189,7 +192,7 @@ class ContactCoordinator @Inject constructor(
189192
// 3. Diff against persisted mappings
190193
val db = FlipcashDatabase.getInstance() ?: run {
191194
_state.update { it.copy(syncState = SyncState.Error) }
192-
return
195+
return Result.failure(IllegalStateException("Database unavailable"))
193196
}
194197
val dao = db.contactDao()
195198
val existingMappings = dao.getAllMappings()
@@ -273,10 +276,12 @@ class ContactCoordinator @Inject constructor(
273276

274277
_state.update { it.copy(syncState = SyncState.Synced) }
275278
trace(tag = TAG, message = "Contact sync complete", type = TraceType.Process)
279+
return Result.success(Unit)
276280

277281
} catch (e: Exception) {
278282
trace(tag = TAG, message = "Contact sync failed: ${e.message}", error = e, type = TraceType.Error)
279283
_state.update { it.copy(syncState = SyncState.Error) }
284+
return Result.failure(e)
280285
}
281286
}
282287

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

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,9 @@ data class DeviceContact(
1010
val displayName: String,
1111
val photoUri: String?,
1212
)
13+
14+
data class PickedContactData(
15+
val phoneNumber: String,
16+
val displayName: String,
17+
val photoUri: String? = null,
18+
)

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,8 @@ class ScopeAwareContactReader @Inject constructor(
1616

1717
override suspend fun readAll(): Result<Map<String, DeviceContact>> = activeReader().readAll()
1818

19-
fun addSelectedContacts(contactIds: List<Long>) {
20-
picker.addPickedContacts(contactIds)
19+
fun addSelectedContacts(contacts: List<PickedContactData>) {
20+
picker.addPickedContacts(contacts)
2121
}
2222

2323
fun reset() {

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

Lines changed: 17 additions & 48 deletions
Original file line numberDiff line numberDiff line change
@@ -1,75 +1,44 @@
11
package com.flipcash.app.contacts.device.internal
22

3-
import android.content.Context
4-
import android.provider.ContactsContract
53
import com.flipcash.app.contacts.device.DeviceContact
64
import com.flipcash.app.contacts.device.DeviceContactReader
5+
import com.flipcash.app.contacts.device.PickedContactData
76
import com.flipcash.app.phone.PhoneUtils
8-
import dagger.hilt.android.qualifiers.ApplicationContext
97
import kotlinx.coroutines.flow.MutableStateFlow
108
import kotlinx.coroutines.flow.update
119
import javax.inject.Inject
1210
import javax.inject.Singleton
1311

1412
@Singleton
1513
class PickerContactReader @Inject constructor(
16-
@param:ApplicationContext private val context: Context,
1714
private val phoneUtils: PhoneUtils,
1815
) : DeviceContactReader {
1916

20-
private val pickedContactIds = MutableStateFlow<Set<Long>>(emptySet())
17+
private val pickedContacts = MutableStateFlow<List<PickedContactData>>(emptyList())
2118

22-
fun addPickedContacts(contactIds: List<Long>) {
23-
pickedContactIds.update { it + contactIds }
19+
fun addPickedContacts(contacts: List<PickedContactData>) {
20+
pickedContacts.update { it + contacts }
2421
}
2522

2623
fun clearPickedContacts() {
27-
pickedContactIds.value = emptySet()
24+
pickedContacts.value = emptyList()
2825
}
2926

3027
override suspend fun readAll(): Result<Map<String, DeviceContact>> {
31-
val ids = pickedContactIds.value
32-
if (ids.isEmpty()) return Result.success(emptyMap())
28+
val raw = pickedContacts.value
29+
if (raw.isEmpty()) return Result.success(emptyMap())
3330

3431
val result = mutableMapOf<String, DeviceContact>()
35-
val projection = arrayOf(
36-
ContactsContract.CommonDataKinds.Phone.NUMBER,
37-
ContactsContract.CommonDataKinds.Phone.CONTACT_ID,
38-
ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME,
39-
ContactsContract.CommonDataKinds.Phone.PHOTO_URI,
40-
)
41-
42-
val selection = "${ContactsContract.CommonDataKinds.Phone.CONTACT_ID} IN (${ids.joinToString(",")})"
43-
44-
context.contentResolver.query(
45-
ContactsContract.CommonDataKinds.Phone.CONTENT_URI,
46-
projection,
47-
selection,
48-
null,
49-
null,
50-
)?.use { cursor ->
51-
val numberIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.NUMBER)
52-
val contactIdIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.CONTACT_ID)
53-
val nameIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.DISPLAY_NAME)
54-
val photoIdx = cursor.getColumnIndex(ContactsContract.CommonDataKinds.Phone.PHOTO_URI)
55-
56-
while (cursor.moveToNext()) {
57-
val rawNumber = cursor.getString(numberIdx) ?: continue
58-
val contactId = cursor.getLong(contactIdIdx)
59-
val displayName = cursor.getString(nameIdx) ?: continue
60-
val photoUri = cursor.getString(photoIdx)
61-
62-
val e164 = normalizeToE164(rawNumber) ?: continue
63-
64-
val existing = result[e164]
65-
if (existing == null || (existing.photoUri == null && photoUri != null)) {
66-
result[e164] = DeviceContact(
67-
e164 = e164,
68-
androidContactId = contactId,
69-
displayName = displayName,
70-
photoUri = photoUri,
71-
)
72-
}
32+
for (contact in raw) {
33+
val e164 = normalizeToE164(contact.phoneNumber) ?: continue
34+
val existing = result[e164]
35+
if (existing == null || (existing.photoUri == null && contact.photoUri != null)) {
36+
result[e164] = DeviceContact(
37+
e164 = e164,
38+
androidContactId = 0L,
39+
displayName = contact.displayName,
40+
photoUri = contact.photoUri,
41+
)
7342
}
7443
}
7544

apps/flipcash/shared/featureflags/src/main/kotlin/com/flipcash/app/featureflags/FeatureFlag.kt

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package com.flipcash.app.featureflags
22

3+
import android.os.Build
34
import com.flipcash.app.featureflags.model.BackgroundResetTimeout
45
import com.flipcash.app.core.navigation.NavBarConfig
56
import com.flipcash.app.ksp.annotations.FeatureFlagMarker
@@ -175,7 +176,7 @@ sealed interface FeatureFlag<T: Any> {
175176
override val key: String = "contact_picker_mode"
176177
override val default: Boolean = false
177178
override val launched: Boolean = false
178-
override val visible: Boolean = true
179+
override val visible: Boolean = Build.VERSION.SDK_INT >= Build.VERSION_CODES.CINNAMON_BUN
179180
override val persistLogOut: Boolean = true
180181
}
181182

0 commit comments

Comments
 (0)