diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt index b806b8f639..8a5d072593 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallActivity.kt @@ -41,6 +41,7 @@ import org.thoughtcrime.securesms.R import org.thoughtcrime.securesms.calls.new.NewCallUiState.CallType import org.thoughtcrime.securesms.calls.new.NewCallUiState.UserMessage import org.thoughtcrime.securesms.components.settings.app.AppSettingsActivity +import org.thoughtcrime.securesms.recipients.ui.RecipientLookupFailureMessage import org.thoughtcrime.securesms.recipients.ui.RecipientPicker import org.thoughtcrime.securesms.recipients.ui.RecipientPickerCallbacks import org.thoughtcrime.securesms.recipients.ui.RecipientPickerScaffold @@ -218,19 +219,14 @@ private fun UserMessagesHost( when (userMessage) { null -> {} - is UserMessage.Info.NetworkError -> Dialogs.SimpleMessageDialog( - message = stringResource(R.string.NetworkFailure__network_error_check_your_connection_and_try_again), - dismiss = stringResource(android.R.string.ok), - onDismiss = { onDismiss(userMessage) } - ) + is UserMessage.RecipientLookupFailed -> { + RecipientLookupFailureMessage( + failure = userMessage.failure, + onDismissed = { onDismiss(userMessage) } + ) + } - is UserMessage.Info.RecipientNotSignalUser -> Dialogs.SimpleMessageDialog( - message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone.displayText), - dismiss = stringResource(android.R.string.ok), - onDismiss = { onDismiss(userMessage) } - ) - - is UserMessage.Info.UserAlreadyInAnotherCall -> LaunchedEffect(userMessage) { + is UserMessage.UserAlreadyInAnotherCall -> LaunchedEffect(userMessage) { snackbarHostState.showSnackbar( message = context.getString(R.string.CommunicationActions__you_are_already_in_a_call) ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt index cb940973cd..6e994d5872 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/calls/new/NewCallViewModel.kt @@ -16,7 +16,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.calls.new.NewCallUiState.CallType -import org.thoughtcrime.securesms.calls.new.NewCallUiState.UserMessage.Info +import org.thoughtcrime.securesms.calls.new.NewCallUiState.UserMessage import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.keyvalue.SignalStore @@ -65,33 +65,17 @@ class NewCallViewModel : ViewModel() { } internalUiState.update { it.copy(isLookingUpRecipient = true) } - val lookupResult = withContext(Dispatchers.IO) { - RecipientRepository.lookupNewE164(inputE164 = phone.value) - } - - when (lookupResult) { - is RecipientRepository.LookupResult.Success -> { - val recipient = withContext(Dispatchers.IO) { - Recipient.resolved(lookupResult.recipientId) - } + when (val lookupResult = RecipientRepository.lookup(phone)) { + is RecipientRepository.PhoneLookupResult.Found -> { internalUiState.update { it.copy(isLookingUpRecipient = false) } - openCall(recipient) + openCall(recipient = lookupResult.recipient) } - is RecipientRepository.LookupResult.NotFound, is RecipientRepository.LookupResult.InvalidEntry -> { + is RecipientRepository.LookupResult.Failure -> { internalUiState.update { it.copy( isLookingUpRecipient = false, - userMessage = Info.RecipientNotSignalUser(phone) - ) - } - } - - is RecipientRepository.LookupResult.NetworkError -> { - internalUiState.update { - it.copy( - isLookingUpRecipient = false, - userMessage = Info.NetworkError + userMessage = UserMessage.RecipientLookupFailed(failure = lookupResult) ) } } @@ -120,7 +104,7 @@ class NewCallViewModel : ViewModel() { } fun showUserAlreadyInACall() { - internalUiState.update { it.copy(userMessage = Info.UserAlreadyInAnotherCall) } + internalUiState.update { it.copy(userMessage = UserMessage.UserAlreadyInAnotherCall) } } fun refresh() { @@ -153,11 +137,8 @@ data class NewCallUiState( val userMessage: UserMessage? = null ) { sealed interface UserMessage { - sealed interface Info : UserMessage { - data class RecipientNotSignalUser(val phone: PhoneNumber) : Info - data object UserAlreadyInAnotherCall : Info - data object NetworkError : Info - } + data object UserAlreadyInAnotherCall : UserMessage + data class RecipientLookupFailed(val failure: RecipientRepository.LookupResult.Failure) : UserMessage } sealed interface CallType { diff --git a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt index 501e15c197..f4f1a7fb58 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/contacts/sync/ContactDiscovery.kt @@ -81,13 +81,14 @@ object ContactDiscovery { } @JvmStatic + @JvmOverloads @Throws(IOException::class) @WorkerThread - fun refresh(context: Context, recipients: List, notifyOfNewUsers: Boolean) { + fun refresh(context: Context, recipients: List, notifyOfNewUsers: Boolean, timeoutMs: Long? = null) { refreshRecipients( context = context, descriptor = "refresh-multiple", - refresh = { ContactDiscoveryRefreshV2.refresh(context, recipients) }, + refresh = { ContactDiscoveryRefreshV2.refresh(context, recipients, timeoutMs = timeoutMs) }, removeSystemContactLinksIfMissing = false, notifyOfNewUsers = notifyOfNewUsers ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt index ee57facef6..059f3261ff 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationActivity.kt @@ -50,6 +50,7 @@ import org.thoughtcrime.securesms.conversation.NewConversationUiState.UserMessag import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupActivity import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.ui.RecipientLookupFailureMessage import org.thoughtcrime.securesms.recipients.ui.RecipientPicker import org.thoughtcrime.securesms.recipients.ui.RecipientPickerCallbacks import org.thoughtcrime.securesms.recipients.ui.RecipientPickerScaffold @@ -351,17 +352,12 @@ private fun UserMessagesHost( onDismiss(userMessage) } - is UserMessage.Info.NetworkError -> Dialogs.SimpleMessageDialog( - message = stringResource(R.string.NetworkFailure__network_error_check_your_connection_and_try_again), - dismiss = stringResource(android.R.string.ok), - onDismiss = { onDismiss(userMessage) } - ) - - is UserMessage.Info.RecipientNotSignalUser -> Dialogs.SimpleMessageDialog( - message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone.displayText), - dismiss = stringResource(android.R.string.ok), - onDismiss = { onDismiss(userMessage) } - ) + is UserMessage.Info.RecipientLookupFailed -> { + RecipientLookupFailureMessage( + failure = userMessage.failure, + onDismissed = { onDismiss(userMessage) } + ) + } is UserMessage.Info.UserAlreadyInAnotherCall -> LaunchedEffect(userMessage) { snackbarHostState.showSnackbar( diff --git a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt index 9acfbc9370..ff155522c3 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/conversation/NewConversationViewModel.kt @@ -65,13 +65,11 @@ class NewConversationViewModel : ViewModel() { viewModelScope.launch { internalUiState.update { it.copy(isLookingUpRecipient = true) } - val lookupResult = withContext(Dispatchers.IO) { - RecipientRepository.lookupNewE164(inputE164 = phone.value) - } + when (val lookupResult = RecipientRepository.lookup(phone)) { + is RecipientRepository.PhoneLookupResult.Found -> { + internalUiState.update { it.copy(isLookingUpRecipient = false) } - when (lookupResult) { - is RecipientRepository.LookupResult.Success -> { - val recipient = Recipient.resolved(lookupResult.recipientId) + val recipient = lookupResult.recipient internalUiState.update { it.copy(isLookingUpRecipient = false) } if (recipient.isRegistered && recipient.hasServiceId) { @@ -81,20 +79,11 @@ class NewConversationViewModel : ViewModel() { } } - is RecipientRepository.LookupResult.NotFound, is RecipientRepository.LookupResult.InvalidEntry -> { + is RecipientRepository.LookupResult.Failure -> { internalUiState.update { it.copy( isLookingUpRecipient = false, - userMessage = Info.RecipientNotSignalUser(phone) - ) - } - } - - is RecipientRepository.LookupResult.NetworkError -> { - internalUiState.update { - it.copy( - isLookingUpRecipient = false, - userMessage = Info.NetworkError + userMessage = Info.RecipientLookupFailed(failure = lookupResult) ) } } @@ -180,9 +169,8 @@ data class NewConversationUiState( sealed interface Info : UserMessage { data class RecipientRemoved(val recipient: Recipient) : Info data class RecipientBlocked(val recipient: Recipient) : Info - data class RecipientNotSignalUser(val phone: PhoneNumber) : Info + data class RecipientLookupFailed(val failure: RecipientRepository.LookupResult.Failure) : Info data object UserAlreadyInAnotherCall : Info - data object NetworkError : Info } sealed interface Prompt : UserMessage { diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java index ff9d25ace4..b2217e255b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/addmembers/AddMembersActivity.java @@ -123,9 +123,9 @@ public class AddMembersActivity extends PushContactSelectionActivity implements if (result instanceof RecipientRepository.LookupResult.Success) { enableDone(); callback.accept(true); - } else if (result instanceof RecipientRepository.LookupResult.NotFound || result instanceof RecipientRepository.LookupResult.InvalidEntry) { + } else if (result instanceof RecipientRepository.PhoneLookupResult.NotFound || result instanceof RecipientRepository.PhoneLookupResult.InvalidPhone) { new MaterialAlertDialogBuilder(this) - .setMessage(getString(R.string.NewConversationActivity__s_is_not_a_signal_user, number)) + .setMessage(getString(R.string.RecipientLookup_error__s_is_not_a_signal_user, number)) .setPositiveButton(android.R.string.ok, null) .show(); callback.accept(false); diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt index 602ab4833a..df2737b378 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupActivity.kt @@ -56,6 +56,7 @@ import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.NavTarget import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage import org.thoughtcrime.securesms.groups.ui.creategroup.details.AddGroupDetailsActivity +import org.thoughtcrime.securesms.recipients.ui.RecipientLookupFailureMessage import org.thoughtcrime.securesms.recipients.ui.RecipientPicker import org.thoughtcrime.securesms.recipients.ui.RecipientPickerCallbacks import org.thoughtcrime.securesms.recipients.ui.RecipientPickerScaffold @@ -275,31 +276,15 @@ private fun UserMessagesHost( userMessage: UserMessage?, onDismiss: (UserMessage) -> Unit ) { - val context: Context = LocalContext.current when (userMessage) { null -> {} - is UserMessage.Info.NetworkError -> Dialogs.SimpleMessageDialog( - message = stringResource(R.string.NetworkFailure__network_error_check_your_connection_and_try_again), - dismiss = stringResource(android.R.string.ok), - onDismiss = { onDismiss(userMessage) } - ) - - is UserMessage.Info.RecipientNotSignalUser -> Dialogs.SimpleMessageDialog( - message = stringResource(R.string.NewConversationActivity__s_is_not_a_signal_user, userMessage.phone.displayText), - dismiss = stringResource(android.R.string.ok), - onDismiss = { onDismiss(userMessage) } - ) - - is UserMessage.Info.RecipientsNotSignalUsers -> Dialogs.SimpleMessageDialog( - message = pluralStringResource( - id = R.plurals.CreateGroupActivity_not_signal_users, - count = userMessage.recipients.size, - userMessage.recipients.joinToString(", ") { it.getDisplayName(context) } - ), - dismiss = stringResource(android.R.string.ok), - onDismiss = { onDismiss(userMessage) } - ) + is UserMessage.RecipientLookupFailed -> { + RecipientLookupFailureMessage( + failure = userMessage.failure, + onDismissed = { onDismiss(userMessage) } + ) + } } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt index f6a3087878..8adef654b5 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/groups/ui/creategroup/CreateGroupViewModel.kt @@ -7,30 +7,22 @@ package org.thoughtcrime.securesms.groups.ui.creategroup import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.signal.core.util.Stopwatch import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.contacts.SelectedContact -import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery -import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.groups.SelectionLimits import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.NavTarget -import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage.Info +import org.thoughtcrime.securesms.groups.ui.creategroup.CreateGroupUiState.UserMessage import org.thoughtcrime.securesms.keyvalue.SignalStore import org.thoughtcrime.securesms.recipients.PhoneNumber -import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientRepository import org.thoughtcrime.securesms.recipients.ui.RecipientSelection import org.thoughtcrime.securesms.util.RemoteConfig -import java.io.IOException -import kotlin.time.Duration.Companion.seconds class CreateGroupViewModel : ViewModel() { companion object { @@ -52,31 +44,17 @@ class CreateGroupViewModel : ViewModel() { private suspend fun recipientExists(phone: PhoneNumber): Boolean { internalUiState.update { it.copy(isLookingUpRecipient = true) } - val lookupResult = withContext(Dispatchers.IO) { - RecipientRepository.lookupNewE164(inputE164 = phone.value) - } - - return when (lookupResult) { - is RecipientRepository.LookupResult.Success -> { + return when (val lookupResult = RecipientRepository.lookup(phone)) { + is RecipientRepository.PhoneLookupResult.Found -> { internalUiState.update { it.copy(isLookingUpRecipient = false) } true } - is RecipientRepository.LookupResult.NotFound, is RecipientRepository.LookupResult.InvalidEntry -> { + is RecipientRepository.LookupResult.Failure -> { internalUiState.update { it.copy( isLookingUpRecipient = false, - userMessage = Info.RecipientNotSignalUser(phone) - ) - } - false - } - - is RecipientRepository.LookupResult.NetworkError -> { - internalUiState.update { - it.copy( - isLookingUpRecipient = false, - userMessage = Info.NetworkError + userMessage = UserMessage.RecipientLookupFailed(failure = lookupResult) ) } false @@ -108,58 +86,31 @@ class CreateGroupViewModel : ViewModel() { fun continueToGroupDetails() { viewModelScope.launch { - val stopwatch = Stopwatch(title = "Recipient Refresh") internalUiState.update { it.copy(isLookingUpRecipient = true) } - val selectedRecipients = uiState.value.newSelections.asRecipients(stopwatch) - stopwatch.split(label = "registered") - stopwatch.stop(tag = TAG) - - val notSignalUsers = selectedRecipients.filter { !it.isRegistered || !it.hasServiceId } - if (notSignalUsers.isNotEmpty()) { - internalUiState.update { - it.copy( - isLookingUpRecipient = false, - userMessage = Info.RecipientsNotSignalUsers(recipients = notSignalUsers) - ) + val selectedRecipientIds = uiState.value.newSelections.map { it.orCreateRecipientId } + when (val lookupResult = RecipientRepository.lookup(recipientIds = selectedRecipientIds)) { + is RecipientRepository.IdLookupResult.FoundAll -> { + internalUiState.update { + it.copy( + isLookingUpRecipient = false, + pendingDestination = NavTarget.AddGroupDetails(recipientIds = selectedRecipientIds) + ) + } } - } else { - internalUiState.update { - it.copy( - isLookingUpRecipient = false, - pendingDestination = NavTarget.AddGroupDetails(recipientIds = selectedRecipients.map(Recipient::id)) - ) + + is RecipientRepository.LookupResult.Failure -> { + internalUiState.update { + it.copy( + isLookingUpRecipient = false, + userMessage = UserMessage.RecipientLookupFailed(failure = lookupResult) + ) + } } } } } - private fun List.asRecipients(stopwatch: Stopwatch): List { - val selectedRecipientIds: List = this.map { it.orCreateRecipientId } - - val recipientsNeedingRegistrationCheck = Recipient - .resolvedList(selectedRecipientIds) - .also { stopwatch.split(label = "resolve") } - .filter { !it.isRegistered || !it.hasServiceId } - .toSet() - - Log.d(TAG, "Need to do ${recipientsNeedingRegistrationCheck.size} registration checks.") - recipientsNeedingRegistrationCheck.forEach { recipient -> - try { - ContactDiscovery.refresh( - context = AppDependencies.application, - recipient = recipient, - notifyOfNewUsers = false, - timeoutMs = 10.seconds.inWholeMilliseconds - ) - } catch (e: IOException) { - Log.w(TAG, "Failed to refresh registered status for ${recipient.id}", e) - } - } - - return Recipient.resolvedList(selectedRecipientIds) - } - fun clearUserMessage() { internalUiState.update { it.copy(userMessage = null) } } @@ -181,11 +132,7 @@ data class CreateGroupUiState( val userMessage: UserMessage? = null ) { sealed interface UserMessage { - sealed interface Info : UserMessage { - data class RecipientNotSignalUser(val phone: PhoneNumber) : Info - data class RecipientsNotSignalUsers(val recipients: List) : Info - data object NetworkError : Info - } + data class RecipientLookupFailed(val failure: RecipientRepository.LookupResult.Failure) : UserMessage } sealed interface NavTarget { diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientRepository.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientRepository.kt index b119d42561..913d7ffe79 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientRepository.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/RecipientRepository.kt @@ -6,12 +6,16 @@ package org.thoughtcrime.securesms.recipients import androidx.annotation.WorkerThread +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import org.signal.core.util.logging.Log import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery import org.thoughtcrime.securesms.database.SignalDatabase +import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.phonenumbers.NumberUtil import org.thoughtcrime.securesms.util.SignalE164Util import java.io.IOException +import kotlin.time.Duration.Companion.seconds /** * We operate on recipients many places, but sometimes we find ourselves performing the same recipient-related operations in several locations. @@ -21,24 +25,68 @@ object RecipientRepository { private val TAG = Log.tag(RecipientRepository::class.java) + /** + * Validates whether the provided [PhoneNumber] is a registered Signal user. Checks locally first, then queries the CDSI directory if needed. + */ + suspend fun lookup(phone: PhoneNumber): PhoneLookupResult { + return withContext(Dispatchers.IO) { + lookupNewE164(inputE164 = phone.value) + } + } + + /** + * Validates whether the provided [RecipientId]s are registered Signal users. + */ + suspend fun lookup(recipientIds: List): IdLookupResult { + val recipientsNeedingRegistrationCheck = Recipient + .resolvedList(recipientIds) + .filter { !it.isRegistered || !it.hasServiceId } + .toSet() + + Log.d(TAG, "Need to do ${recipientsNeedingRegistrationCheck.size} registration checks.") + + withContext(Dispatchers.IO) { + try { + ContactDiscovery.refresh( + context = AppDependencies.application, + recipients = recipientsNeedingRegistrationCheck.toList(), + notifyOfNewUsers = false, + timeoutMs = 30.seconds.inWholeMilliseconds + ) + } catch (e: IOException) { + Log.w(TAG, "Failed to refresh registered status for ${recipientsNeedingRegistrationCheck.size} recipients", e) + } + } + + val allRecipients = Recipient.resolvedList(recipientIds) + val (registeredRecipients, unregisteredRecipients) = allRecipients.partition { it.isRegistered && it.hasServiceId } + return if (unregisteredRecipients.isNotEmpty()) { + Log.w(TAG, "Found ${unregisteredRecipients.size} non-Signal users: ${unregisteredRecipients.joinToString(", ") { it.id.toString() }}") + IdLookupResult.FoundSome(found = registeredRecipients, notFound = unregisteredRecipients) + } else { + IdLookupResult.FoundAll(registeredRecipients) + } + } + /** * Attempts to lookup a potentially-new recipient by their e164. * We will check locally first for a potential match, but may end up hitting the network. * This will not create a new recipient if we could not find it in the CDSI directory. */ + @Deprecated("Use [RecipientRepository.lookup(PhoneNumber)] instead.") @WorkerThread @JvmStatic - fun lookupNewE164(inputE164: String): LookupResult { + fun lookupNewE164(inputE164: String): PhoneLookupResult { val e164 = SignalE164Util.formatAsE164(inputE164) if (e164 == null || !NumberUtil.isVisuallyValidNumber(e164)) { - return LookupResult.InvalidEntry + return PhoneLookupResult.InvalidPhone(inputE164) } val matchingFullRecipientId = SignalDatabase.recipients.getByE164IfRegisteredAndDiscoverable(e164) if (matchingFullRecipientId != null) { Log.i(TAG, "Already have a full, discoverable recipient for $e164. $matchingFullRecipientId") - return LookupResult.Success(matchingFullRecipientId) + return PhoneLookupResult.Found(recipient = Recipient.resolved(matchingFullRecipientId), phone = PhoneNumber(e164)) } Log.i(TAG, "Need to lookup up $e164 with CDSI.") @@ -46,19 +94,32 @@ object RecipientRepository { return try { val result = ContactDiscovery.lookupE164(e164) if (result == null) { - LookupResult.NotFound() + PhoneLookupResult.NotFound(PhoneNumber(e164)) } else { - LookupResult.Success(result.recipientId) + PhoneLookupResult.Found(recipient = Recipient.resolved(result.recipientId), phone = PhoneNumber(e164)) } - } catch (e: IOException) { + } catch (_: IOException) { return LookupResult.NetworkError } } sealed interface LookupResult { - data class Success(val recipientId: RecipientId) : LookupResult - object InvalidEntry : LookupResult - data class NotFound(val recipientId: RecipientId = RecipientId.UNKNOWN) : LookupResult - object NetworkError : LookupResult + sealed interface Success : LookupResult + sealed interface Failure : LookupResult + + data object NetworkError : PhoneLookupResult, IdLookupResult, Failure + } + + sealed interface PhoneLookupResult : LookupResult { + sealed interface NoResult : PhoneLookupResult, LookupResult.Failure + + data class Found(val recipient: Recipient, val phone: PhoneNumber) : PhoneLookupResult, LookupResult.Success + data class NotFound(val phone: PhoneNumber) : NoResult + data class InvalidPhone(val invalidValue: String) : NoResult + } + + sealed interface IdLookupResult : LookupResult { + data class FoundAll(val found: List) : IdLookupResult, LookupResult.Success + data class FoundSome(val found: List, val notFound: List) : IdLookupResult, LookupResult.Failure } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientLookupFailureMessage.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientLookupFailureMessage.kt new file mode 100644 index 0000000000..3d3aa62891 --- /dev/null +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/RecipientLookupFailureMessage.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui + +import android.content.Context +import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import org.signal.core.ui.compose.Dialogs +import org.thoughtcrime.securesms.R +import org.thoughtcrime.securesms.recipients.RecipientRepository +import org.thoughtcrime.securesms.recipients.RecipientRepository.IdLookupResult +import org.thoughtcrime.securesms.recipients.RecipientRepository.PhoneLookupResult + +/** + * Handles displaying a message to the user when a recipient lookup fails. + */ +@Composable +fun RecipientLookupFailureMessage( + failure: RecipientRepository.LookupResult.Failure, + onDismissed: () -> Unit +) { + val context: Context = LocalContext.current + + when (failure) { + is PhoneLookupResult.NotFound, + is PhoneLookupResult.InvalidPhone -> { + val phoneDisplayText: String = when (failure) { + is PhoneLookupResult.NotFound -> failure.phone.displayText + is PhoneLookupResult.InvalidPhone -> failure.invalidValue + } + + Dialogs.SimpleMessageDialog( + message = stringResource(R.string.RecipientLookup_error__s_is_not_a_signal_user, phoneDisplayText), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onDismissed() } + ) + } + + is IdLookupResult.FoundSome -> Dialogs.SimpleMessageDialog( + message = pluralStringResource( + id = R.plurals.RecipientLookup_error__not_signal_users, + count = failure.notFound.size, + failure.notFound.joinToString(", ") { it.getDisplayName(context) } + ), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onDismissed() } + ) + + is RecipientRepository.LookupResult.NetworkError -> Dialogs.SimpleMessageDialog( + message = stringResource(R.string.NetworkFailure__network_error_check_your_connection_and_try_again), + dismiss = stringResource(android.R.string.ok), + onDismiss = { onDismissed() } + ) + } +} diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt index a1593936cc..00b1c12f42 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt @@ -13,6 +13,7 @@ import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import org.thoughtcrime.securesms.profiles.manage.UsernameRepository +import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientRepository import org.thoughtcrime.securesms.registration.ui.countrycode.Country @@ -71,19 +72,18 @@ class FindByViewModel( } } - @WorkerThread - private fun performPhoneLookup(): FindByResult { + private suspend fun performPhoneLookup(): FindByResult { val stateSnapshot = state.value val countryCode = stateSnapshot.selectedCountry.countryCode val nationalNumber = stateSnapshot.userEntry.removePrefix(countryCode.toString()) val e164 = "+$countryCode$nationalNumber" - return when (val result = RecipientRepository.lookupNewE164(e164)) { - RecipientRepository.LookupResult.InvalidEntry -> FindByResult.InvalidEntry - RecipientRepository.LookupResult.NetworkError -> FindByResult.NetworkError - is RecipientRepository.LookupResult.NotFound -> FindByResult.NotFound(result.recipientId) - is RecipientRepository.LookupResult.Success -> FindByResult.Success(result.recipientId) + return when (val result = RecipientRepository.lookup(PhoneNumber(e164))) { + is RecipientRepository.PhoneLookupResult.InvalidPhone -> FindByResult.InvalidEntry + is RecipientRepository.PhoneLookupResult.NotFound -> FindByResult.NotFound() + is RecipientRepository.PhoneLookupResult.Found -> FindByResult.Success(result.recipient.id) + is RecipientRepository.LookupResult.NetworkError -> FindByResult.NetworkError } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java index 2bc534b63e..1c9c643079 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/CommunicationActions.java @@ -452,7 +452,7 @@ public class CommunicationActions { startConversation(activity, recipient, null); } else { new MaterialAlertDialogBuilder(activity) - .setMessage(activity.getString(R.string.NewConversationActivity__s_is_not_a_signal_user, e164)) + .setMessage(activity.getString(R.string.RecipientLookup_error__s_is_not_a_signal_user, e164)) .setPositiveButton(android.R.string.ok, null) .show(); } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index cdf9206bc8..ba73f4d6ba 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -5943,11 +5943,11 @@ Block failed - %1$s is not a Signal user + %1$s is not a Signal user Open menu - + %1$s is not a Signal user %1$s are not Signal users diff --git a/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModelTest.kt index 101d8fe789..f99b8e52a2 100644 --- a/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModelTest.kt +++ b/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModelTest.kt @@ -21,6 +21,7 @@ import org.junit.Test import org.thoughtcrime.securesms.dependencies.AppDependencies import org.thoughtcrime.securesms.profiles.manage.UsernameRepository import org.thoughtcrime.securesms.recipients.LiveRecipientCache +import org.thoughtcrime.securesms.recipients.PhoneNumber import org.thoughtcrime.securesms.recipients.Recipient import org.thoughtcrime.securesms.recipients.RecipientId import org.thoughtcrime.securesms.recipients.RecipientRepository @@ -95,7 +96,7 @@ class FindByViewModelTest { viewModel.onUserEntryChanged("") mockkStatic(RecipientRepository::class).apply { - every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.LookupResult.InvalidEntry + every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.PhoneLookupResult.InvalidPhone(invalidValue = "") } val result = viewModel.onNextClicked() @@ -107,11 +108,13 @@ class FindByViewModelTest { @Test fun `Given valid phone lookup, when I click next, then I expect Success`() = runTest { - val recipientId = RecipientId.from(123L) + val recipient = mockk { + every { id } returns RecipientId.from(123L) + } mockkStatic(RecipientRepository::class).apply { every { RecipientRepository.lookupNewE164("+15551234") } returns - RecipientRepository.LookupResult.Success(recipientId) + RecipientRepository.PhoneLookupResult.Found(recipient, PhoneNumber("+15551234")) } viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) @@ -123,17 +126,15 @@ class FindByViewModelTest { val result = viewModel.onNextClicked() assertTrue(result is FindByResult.Success) - assertEquals((result as FindByResult.Success).recipientId, recipientId) + assertEquals((result as FindByResult.Success).recipientId, recipient.id) unmockkObject(RecipientRepository) } @Test fun `Given unknown phone lookup, when I click next, then I expect NotFound`() = runTest { - val recipientId = RecipientId.from(123L) - mockkStatic(RecipientRepository::class).apply { - every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.LookupResult.NotFound(recipientId) + every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.PhoneLookupResult.NotFound(PhoneNumber("0000000000")) } viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) @@ -142,7 +143,7 @@ class FindByViewModelTest { val result = viewModel.onNextClicked() assertTrue(result is FindByResult.NotFound) - assertEquals((result as FindByResult.NotFound).recipientId, recipientId) + assertEquals(RecipientId.from(-1L), (result as FindByResult.NotFound).recipientId) unmockkObject(RecipientRepository) }