diff --git a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/IntentUtils.kt b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/IntentUtils.kt index 3f2a2f240..df54d80a4 100644 --- a/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/IntentUtils.kt +++ b/apps/flipcash/core/src/main/kotlin/com/flipcash/app/core/android/IntentUtils.kt @@ -5,10 +5,12 @@ import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.Uri +import android.provider.ContactsContract import android.provider.MediaStore import android.provider.Settings import androidx.core.content.FileProvider import androidx.core.net.toUri +import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.app.core.util.Linkify import java.io.File @@ -59,6 +61,23 @@ object IntentUtils { flags = Intent.FLAG_ACTIVITY_NEW_TASK } + fun openContact(contact: DeviceContact): Intent = if (!contact.isUnknown) { + openContact(contact.androidContactId) + } else { + insertOrEditContact(contact.e164) + } + + fun openContact(contactId: Long) = Intent(Intent.ACTION_VIEW).apply { + data = Uri.withAppendedPath(ContactsContract.Contacts.CONTENT_URI, contactId.toString()) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + + fun insertOrEditContact(phoneNumber: String) = Intent(Intent.ACTION_INSERT_OR_EDIT).apply { + type = ContactsContract.Contacts.CONTENT_ITEM_TYPE + putExtra(ContactsContract.Intents.Insert.PHONE, phoneNumber) + flags = Intent.FLAG_ACTIVITY_NEW_TASK + } + fun shareFile(context: Context, file: File, mimeType: String): Intent { val uri = FileProvider.getUriForFile( context, diff --git a/apps/flipcash/core/src/main/res/values/strings.xml b/apps/flipcash/core/src/main/res/values/strings.xml index a04eef56e..cbfd24df0 100644 --- a/apps/flipcash/core/src/main/res/values/strings.xml +++ b/apps/flipcash/core/src/main/res/values/strings.xml @@ -779,4 +779,7 @@ Send Money To Your Friends Send money to friends as easily as a text. Connect your phone number to get started + From Your Contacts + Add Contact + \ No newline at end of file diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt index f07750d25..36c49baa5 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/ChatViewModel.kt @@ -123,6 +123,7 @@ internal class ChatViewModel @Inject constructor( sealed interface Event { data class OnChatOpened(val identifier: ChatIdentifier) : Event data class OnContactFound(val contact: DeviceContact): Event + data object RefreshContact : Event data class ChatFound(val chatId: ChatId) : Event data object OnSendCash: Event data object OnStartMessageInput: Event @@ -303,6 +304,18 @@ internal class ChatViewModel @Inject constructor( } ).launchIn(viewModelScope) + // Re-resolve the contact from the device (e.g. after adding via system contacts) + eventFlow + .filterIsInstance() + .mapNotNull { stateFlow.value.chattingWith?.e164 } + .onEach { e164 -> + val refreshed = contactCoordinator.refreshContact(e164) + if (refreshed != null) { + dispatchEvent(Event.OnContactFound(refreshed)) + } + } + .launchIn(viewModelScope) + // Advance read pointer when user scrolls to messages eventFlow .filterIsInstance() @@ -641,6 +654,7 @@ internal class ChatViewModel @Inject constructor( chattingWith = event.contact ) } + is Event.RefreshContact -> { state -> state } is Event.ChatFound -> { state -> state.copy(chatId = event.chatId) } Event.OnSendCash -> { state -> state } Event.OnStartMessageInput -> { state -> state.copy(userState = UserState.Typing) } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt index 28f978417..2f59ca668 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/MessengerScreen.kt @@ -106,6 +106,9 @@ internal fun MessengerScreen(viewModel: ChatViewModel) { onAdvanceReadPointer = { messageId -> viewModel.dispatchEvent(ChatViewModel.Event.AdvanceReadPointer(messageId)) }, + onRefreshContact = { + viewModel.dispatchEvent(ChatViewModel.Event.RefreshContact) + }, ) } } diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt index ab6a4d850..52e4fdb0b 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/ContactInfoContainer.kt @@ -4,6 +4,7 @@ import androidx.compose.animation.AnimatedContent import androidx.compose.animation.animateColorAsState import androidx.compose.foundation.background import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -15,14 +16,19 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.text.TextAutoSize import androidx.compose.material3.Icon import androidx.compose.material3.Text +import android.content.Intent +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import com.flipcash.app.contacts.ui.ContactAvatar +import com.flipcash.app.core.android.IntentUtils import com.flipcash.app.core.contacts.DeviceContact import com.flipcash.features.messenger.R import com.getcode.theme.CodeTheme @@ -31,6 +37,7 @@ import com.getcode.theme.CodeTheme internal fun ContactInfoContainer( contact: DeviceContact?, modifier: Modifier = Modifier, + onRefreshContact: () -> Unit = {}, ) { Column( modifier = modifier @@ -61,17 +68,39 @@ internal fun ContactInfoContainer( color = CodeTheme.colors.textMain, ) + if (contact?.isUnknown == false) { + Text( + modifier = Modifier.padding(top = CodeTheme.dimens.grid.x1), + text = contact.displayNumber, + style = CodeTheme.typography.textSmall, + color = CodeTheme.colors.textSecondary, + ) + } + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { onRefreshContact() } + ContactPill( modifier = Modifier.padding(top = CodeTheme.dimens.inset), contact = contact - ) + ) { + contact ?: return@ContactPill + val intent = IntentUtils.openContact(contact).apply { + // Remove NEW_TASK so the result callback fires when the user returns, + // not immediately. + flags = flags and Intent.FLAG_ACTIVITY_NEW_TASK.inv() + } + launcher.launch(intent) + } } } @Composable private fun ContactPill( contact: DeviceContact?, - modifier: Modifier = Modifier + modifier: Modifier = Modifier, + onClick: () -> Unit, ) { AnimatedContent(contact) { c -> if (c == null) { @@ -79,18 +108,19 @@ private fun ContactPill( return@AnimatedContent } - val isContact = !c.isUnknown val backgroundColor by animateColorAsState( - if (isContact) CodeTheme.colors.surfaceVariant else CodeTheme.colors.warning.copy(alpha = 0.10f) + if (!c.isUnknown) CodeTheme.colors.surfaceVariant else CodeTheme.colors.warning.copy(alpha = 0.10f) ) val contentColor by animateColorAsState( - if (isContact) CodeTheme.colors.textSecondary else CodeTheme.colors.warning + if (!c.isUnknown) CodeTheme.colors.textSecondary else CodeTheme.colors.warning ) Row( modifier = modifier .background(color = backgroundColor, shape = CircleShape) + .clip(CircleShape) + .clickable { onClick() } .padding(horizontal = CodeTheme.dimens.grid.x2, vertical = CodeTheme.dimens.grid.x1), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(CodeTheme.dimens.grid.x1) @@ -98,7 +128,7 @@ private fun ContactPill( Icon( modifier = Modifier.size(CodeTheme.dimens.staticGrid.x4), painter = painterResource( - if (isContact) { + if (!c.isUnknown) { R.drawable.ic_existing_contact } else { R.drawable.ic_unknown_contact @@ -109,7 +139,7 @@ private fun ContactPill( ) Text( - text = if (isContact) "From Your Contacts" else "Unknown Contact", + text = if (!c.isUnknown) stringResource(R.string.label_fromYourContacts) else stringResource(R.string.label_addContact), color = contentColor, style = CodeTheme.typography.textSmall, ) diff --git a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt index 0d28a1227..30165c4b1 100644 --- a/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt +++ b/apps/flipcash/features/messenger/src/main/kotlin/com/flipcash/app/messenger/internal/screens/components/MessageList.kt @@ -57,6 +57,7 @@ internal fun MessageList( separatorConfig: SeparatorConfig, otherReadPointer: MessagePointer? = null, onAdvanceReadPointer: ((Long) -> Unit)? = null, + onRefreshContact: () -> Unit = {}, ) { val keyboard = rememberKeyboardController() val listState = rememberLazyListState() @@ -208,7 +209,8 @@ internal fun MessageList( ContactInfoContainer( contact = state.chattingWith, modifier = Modifier - .padding(horizontal = CodeTheme.dimens.grid.x12) + .padding(horizontal = CodeTheme.dimens.grid.x12), + onRefreshContact = onRefreshContact, ) } } 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 index 05fc2866b..66517ae33 100644 --- 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 @@ -13,6 +13,7 @@ import androidx.datastore.preferences.preferencesDataStoreFile import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.ProcessLifecycleOwner +import com.flipcash.app.contacts.device.DeviceContactLookup import com.flipcash.app.contacts.device.PickedContactData import com.flipcash.app.contacts.device.ScopeAwareContactReader import com.flipcash.app.featureflags.FeatureFlag @@ -70,6 +71,7 @@ class ContactCoordinator @Inject constructor( private val contactVerificationController: ContactVerificationController, private val resolverController: ResolverController, private val networkObserver: NetworkConnectivityListener, + private val deviceContactLookup: DeviceContactLookup, private val contactReader: ScopeAwareContactReader, private val phoneUtils: PhoneUtils, private val contactDataSource: ContactDataSource, @@ -197,6 +199,13 @@ class ContactCoordinator @Inject constructor( return resolverController.resolve(ContactMethod.Phone(e164)) } + fun refreshContact(e164: String): DeviceContact? { + val refreshed = deviceContactLookup.lookupContact(e164) ?: return null + val enriched = refreshed.copy(displayNumber = phoneUtils.formatNumber(e164)) + _state.update { it.copy(contacts = it.contacts + (e164 to enriched)) } + return enriched + } + fun lookupContact(e164: String): Result { val contact = _state.value.contacts[e164] ?: return Result.failure(NoSuchElementException("No contact found for $e164")) diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt index f55af5330..e6ba8a743 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/DeviceContactLookup.kt @@ -1,8 +1,11 @@ package com.flipcash.app.contacts.device +import com.flipcash.app.core.contacts.DeviceContact + interface DeviceContactLookup { fun lookupDisplayName(e164: String): String? fun lookupPhotoUri(e164: String): String? fun lookupPhotoBytes(e164: String): ByteArray? fun lookupProfilePhoto(): ByteArray? + fun lookupContact(e164: String): DeviceContact? } \ No newline at end of file diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt index 0d120fc28..0c7d361b6 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/AndroidDeviceContactLookup.kt @@ -7,6 +7,7 @@ import android.content.pm.PackageManager import android.provider.ContactsContract import androidx.core.content.ContextCompat import com.flipcash.app.contacts.device.DeviceContactLookup +import com.flipcash.app.core.contacts.DeviceContact import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject import javax.inject.Singleton @@ -50,6 +51,40 @@ internal class AndroidDeviceContactLookup @Inject constructor( } } + override fun lookupContact(e164: String): DeviceContact? { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_CONTACTS) + != PackageManager.PERMISSION_GRANTED + ) return null + + val uri = ContactsContract.PhoneLookup.CONTENT_FILTER_URI + .buildUpon() + .appendPath(e164) + .build() + + return try { + context.contentResolver.query( + uri, + arrayOf( + ContactsContract.PhoneLookup._ID, + ContactsContract.PhoneLookup.DISPLAY_NAME, + ContactsContract.PhoneLookup.PHOTO_URI, + ), + null, null, null + )?.use { cursor -> + if (cursor.moveToFirst()) { + DeviceContact( + e164 = e164, + androidContactId = cursor.getLong(0), + displayName = cursor.getString(1) ?: e164, + photoUri = cursor.getString(2), + ) + } else null + } + } catch (e: Exception) { + null + } + } + private fun lookupContactId(e164: String): Long? = lookupField(e164, ContactsContract.PhoneLookup._ID)?.toLongOrNull()