mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-29 13:16:01 +01:00
Add Find By Username and Find By Phone Number interstitials.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
committed by
Greyson Parrelli
parent
ca3d239ce2
commit
700fe5e463
@@ -0,0 +1,558 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.result.contract.ActivityResultContract
|
||||
import androidx.compose.animation.slideInHorizontally
|
||||
import androidx.compose.animation.slideOutHorizontally
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement.Absolute.spacedBy
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.focus.FocusRequester
|
||||
import androidx.compose.ui.focus.focusRequester
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.res.dimensionResource
|
||||
import androidx.compose.ui.res.painterResource
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.AnnotatedString
|
||||
import androidx.compose.ui.text.SpanStyle
|
||||
import androidx.compose.ui.text.buildAnnotatedString
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.text.intl.Locale
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import androidx.navigation.NavType
|
||||
import androidx.navigation.compose.NavHost
|
||||
import androidx.navigation.compose.composable
|
||||
import androidx.navigation.compose.dialog
|
||||
import androidx.navigation.compose.rememberNavController
|
||||
import androidx.navigation.navArgument
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Buttons
|
||||
import org.signal.core.ui.Dialogs
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.TextFields
|
||||
import org.signal.core.ui.theme.SignalTheme
|
||||
import org.signal.core.util.getParcelableExtraCompat
|
||||
import org.thoughtcrime.securesms.PassphraseRequiredActivity
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.invites.InviteActions
|
||||
import org.thoughtcrime.securesms.phonenumbers.PhoneNumberVisualTransformation
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
import org.thoughtcrime.securesms.util.viewModel
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter
|
||||
|
||||
/**
|
||||
* Allows the user to look up another Signal user by phone number or username and
|
||||
* retrieve a RecipientId for that data.
|
||||
*/
|
||||
class FindByActivity : PassphraseRequiredActivity() {
|
||||
|
||||
companion object {
|
||||
private const val MODE = "FindByActivity.mode"
|
||||
private const val RECIPIENT_ID = "FindByActivity.recipientId"
|
||||
}
|
||||
|
||||
private val viewModel: FindByViewModel by viewModel {
|
||||
FindByViewModel(FindByMode.valueOf(intent.getStringExtra(MODE)!!))
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?, ready: Boolean) {
|
||||
setContent {
|
||||
val state by viewModel.state
|
||||
|
||||
val navController = rememberNavController()
|
||||
|
||||
SignalTheme {
|
||||
NavHost(
|
||||
navController = navController,
|
||||
startDestination = "find-by-content",
|
||||
enterTransition = { slideInHorizontally(initialOffsetX = { it }) },
|
||||
exitTransition = { slideOutHorizontally(targetOffsetX = { -it }) },
|
||||
popEnterTransition = { slideInHorizontally(initialOffsetX = { -it }) },
|
||||
popExitTransition = { slideOutHorizontally(targetOffsetX = { it }) }
|
||||
) {
|
||||
composable("find-by-content") {
|
||||
val title = remember(state.mode) {
|
||||
if (state.mode == FindByMode.USERNAME) R.string.FindByActivity__find_by_username else R.string.FindByActivity__find_by_phone_number
|
||||
}
|
||||
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = title),
|
||||
onNavigationClick = { finishAfterTransition() },
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
|
||||
) {
|
||||
val context = LocalContext.current
|
||||
FindByContent(
|
||||
paddingValues = it,
|
||||
state = state,
|
||||
onUserEntryChanged = viewModel::onUserEntryChanged,
|
||||
onNextClick = {
|
||||
lifecycleScope.launch {
|
||||
when (val result = viewModel.onNextClicked(context)) {
|
||||
is FindByResult.Success -> {
|
||||
setResult(RESULT_OK, Intent().putExtra(RECIPIENT_ID, result.recipientId))
|
||||
finishAfterTransition()
|
||||
}
|
||||
|
||||
FindByResult.InvalidEntry -> navController.navigate("invalid-entry")
|
||||
is FindByResult.NotFound -> navController.navigate("not-found/${result.recipientId.toLong()}")
|
||||
}
|
||||
}
|
||||
},
|
||||
onSelectCountryPrefixClick = {
|
||||
navController.navigate("select-country-prefix")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
composable("select-country-prefix") {
|
||||
Scaffolds.Settings(
|
||||
title = stringResource(id = R.string.FindByActivity__select_country_code),
|
||||
onNavigationClick = { navController.popBackStack() },
|
||||
navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_left_24)
|
||||
) { paddingValues ->
|
||||
SelectCountryScreen(
|
||||
paddingValues = paddingValues,
|
||||
searchEntry = state.countryPrefixSearchEntry,
|
||||
onSearchEntryChanged = viewModel::onCountryPrefixSearchEntryChanged,
|
||||
supportedCountryPrefixes = state.supportedCountryPrefixes,
|
||||
onCountryPrefixSelected = {
|
||||
navController.popBackStack()
|
||||
viewModel.onCountryPrefixSelected(it)
|
||||
viewModel.onCountryPrefixSearchEntryChanged("")
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
dialog("invalid-entry") {
|
||||
val title = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = R.string.FindByActivity__invalid_username)
|
||||
} else {
|
||||
stringResource(id = R.string.FindByActivity__invalid_phone_number)
|
||||
}
|
||||
|
||||
val body = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = R.string.FindByActivity__s_is_not_a_valid_username, state.userEntry)
|
||||
} else {
|
||||
val formattedNumber = remember(state.userEntry) {
|
||||
val cleansed = state.userEntry.removePrefix(state.selectedCountryPrefix.digits.toString())
|
||||
PhoneNumberFormatter.formatE164(state.selectedCountryPrefix.digits.toString(), cleansed)
|
||||
}
|
||||
stringResource(id = R.string.FindByActivity__s_is_not_a_valid_phone_number, formattedNumber)
|
||||
}
|
||||
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = title,
|
||||
body = body,
|
||||
confirm = stringResource(id = android.R.string.ok),
|
||||
onConfirm = {},
|
||||
onDismiss = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
|
||||
dialog(
|
||||
route = "not-found/{recipientId}",
|
||||
arguments = listOf(navArgument("recipientId") { type = NavType.LongType })
|
||||
) { navBackStackEntry ->
|
||||
val title = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = R.string.FindByActivity__username_not_found)
|
||||
} else {
|
||||
stringResource(id = R.string.FindByActivity__invite_to_signal)
|
||||
}
|
||||
|
||||
val body = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = R.string.FindByActivity__s_is_not_a_signal_user, state.userEntry)
|
||||
} else {
|
||||
val formattedNumber = remember(state.userEntry) {
|
||||
val cleansed = state.userEntry.removePrefix(state.selectedCountryPrefix.digits.toString())
|
||||
PhoneNumberFormatter.formatE164(state.selectedCountryPrefix.digits.toString(), cleansed)
|
||||
}
|
||||
stringResource(id = R.string.FindByActivity__s_is_not_a_signal_user_would, formattedNumber)
|
||||
}
|
||||
|
||||
val confirm = if (state.mode == FindByMode.USERNAME) {
|
||||
stringResource(id = android.R.string.ok)
|
||||
} else {
|
||||
stringResource(id = R.string.FindByActivity__invite)
|
||||
}
|
||||
|
||||
val dismiss = if (state.mode == FindByMode.USERNAME) {
|
||||
Dialogs.NoDismiss
|
||||
} else {
|
||||
stringResource(id = android.R.string.cancel)
|
||||
}
|
||||
|
||||
val context = LocalContext.current
|
||||
Dialogs.SimpleAlertDialog(
|
||||
title = title,
|
||||
body = body,
|
||||
confirm = confirm,
|
||||
dismiss = dismiss,
|
||||
onConfirm = {
|
||||
if (state.mode == FindByMode.PHONE_NUMBER) {
|
||||
val recipientId = navBackStackEntry.arguments?.getLong("recipientId")?.takeIf { it > 0 }?.let { RecipientId.from(it) } ?: RecipientId.UNKNOWN
|
||||
if (recipientId != RecipientId.UNKNOWN) {
|
||||
InviteActions.inviteUserToSignal(
|
||||
context,
|
||||
Recipient.resolved(recipientId),
|
||||
null,
|
||||
this@FindByActivity::startActivity
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
onDismiss = { navController.popBackStack() }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class Contract : ActivityResultContract<FindByMode, RecipientId?>() {
|
||||
override fun createIntent(context: Context, input: FindByMode): Intent {
|
||||
return Intent(context, FindByActivity::class.java)
|
||||
.putExtra(MODE, input.name)
|
||||
}
|
||||
|
||||
override fun parseResult(resultCode: Int, intent: Intent?): RecipientId? {
|
||||
return intent?.getParcelableExtraCompat(RECIPIENT_ID, RecipientId::class.java)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun FindByContentPreview() {
|
||||
Previews.Preview {
|
||||
FindByContent(
|
||||
paddingValues = PaddingValues(0.dp),
|
||||
state = FindByState(
|
||||
mode = FindByMode.PHONE_NUMBER,
|
||||
userEntry = ""
|
||||
),
|
||||
onUserEntryChanged = {},
|
||||
onNextClick = {},
|
||||
onSelectCountryPrefixClick = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FindByContent(
|
||||
paddingValues: PaddingValues,
|
||||
state: FindByState,
|
||||
onUserEntryChanged: (String) -> Unit,
|
||||
onNextClick: () -> Unit,
|
||||
onSelectCountryPrefixClick: () -> Unit
|
||||
) {
|
||||
val placeholderLabel = remember(state.mode) {
|
||||
if (state.mode == FindByMode.PHONE_NUMBER) R.string.FindByActivity__phone_number else R.string.FindByActivity__username
|
||||
}
|
||||
|
||||
val focusRequester = remember {
|
||||
FocusRequester()
|
||||
}
|
||||
|
||||
val keyboardType = remember(state.mode) {
|
||||
if (state.mode == FindByMode.PHONE_NUMBER) {
|
||||
KeyboardType.Phone
|
||||
} else {
|
||||
KeyboardType.Text
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.padding(paddingValues)
|
||||
.fillMaxSize()
|
||||
) {
|
||||
val onNextAction = remember(state.isLookupInProgress) {
|
||||
KeyboardActions(onNext = {
|
||||
if (!state.isLookupInProgress) {
|
||||
onNextClick()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
val visualTransformation = if (state.mode == FindByMode.USERNAME) {
|
||||
VisualTransformation.None
|
||||
} else {
|
||||
remember(state.selectedCountryPrefix) {
|
||||
PhoneNumberVisualTransformation(state.selectedCountryPrefix.regionCode)
|
||||
}
|
||||
}
|
||||
|
||||
TextFields.TextField(
|
||||
enabled = !state.isLookupInProgress,
|
||||
value = state.userEntry,
|
||||
onValueChange = onUserEntryChanged,
|
||||
singleLine = true,
|
||||
placeholder = { Text(text = stringResource(id = placeholderLabel)) },
|
||||
prefix = if (state.mode == FindByMode.USERNAME) {
|
||||
null
|
||||
} else {
|
||||
{
|
||||
PhoneNumberEntryPrefix(
|
||||
enabled = !state.isLookupInProgress,
|
||||
selectedCountryPrefix = state.selectedCountryPrefix,
|
||||
onSelectCountryPrefixClick = onSelectCountryPrefixClick
|
||||
)
|
||||
}
|
||||
},
|
||||
visualTransformation = visualTransformation,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = keyboardType,
|
||||
imeAction = ImeAction.Next
|
||||
),
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
keyboardActions = onNextAction,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.heightIn(min = 44.dp),
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(top = 10.dp, bottom = 10.dp)
|
||||
)
|
||||
|
||||
if (state.mode == FindByMode.USERNAME) {
|
||||
Text(
|
||||
text = stringResource(id = R.string.FindByActivity__enter_a_full_username),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
.padding(top = 8.dp)
|
||||
)
|
||||
}
|
||||
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
|
||||
Box(
|
||||
contentAlignment = Alignment.BottomEnd,
|
||||
modifier = Modifier.fillMaxWidth()
|
||||
) {
|
||||
Buttons.LargeTonal(
|
||||
enabled = !state.isLookupInProgress,
|
||||
onClick = onNextClick,
|
||||
contentPadding = PaddingValues(0.dp),
|
||||
modifier = Modifier
|
||||
.padding(16.dp)
|
||||
.size(48.dp)
|
||||
) {
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_arrow_right_24),
|
||||
contentDescription = stringResource(id = R.string.FindByActivity__next)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhoneNumberEntryPrefix(
|
||||
enabled: Boolean,
|
||||
selectedCountryPrefix: CountryPrefix,
|
||||
onSelectCountryPrefixClick: () -> Unit
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(end = 16.dp)
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onSelectCountryPrefixClick, enabled = enabled)
|
||||
) {
|
||||
Text(
|
||||
text = selectedCountryPrefix.toString()
|
||||
)
|
||||
Icon(
|
||||
painter = painterResource(id = R.drawable.symbol_dropdown_triangle_24),
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
Dividers.Vertical(
|
||||
thickness = 1.dp,
|
||||
color = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier
|
||||
.padding(vertical = 2.dp)
|
||||
.padding(start = 8.dp)
|
||||
.height(20.dp)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun SelectCountryScreenPreview() {
|
||||
Previews.Preview {
|
||||
SelectCountryScreen(
|
||||
paddingValues = PaddingValues(0.dp),
|
||||
searchEntry = "",
|
||||
onSearchEntryChanged = {},
|
||||
supportedCountryPrefixes = FindByState(mode = FindByMode.PHONE_NUMBER).supportedCountryPrefixes,
|
||||
onCountryPrefixSelected = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SelectCountryScreen(
|
||||
paddingValues: PaddingValues,
|
||||
searchEntry: String,
|
||||
onSearchEntryChanged: (String) -> Unit,
|
||||
onCountryPrefixSelected: (CountryPrefix) -> Unit,
|
||||
supportedCountryPrefixes: List<CountryPrefix>
|
||||
) {
|
||||
val focusRequester = remember {
|
||||
FocusRequester()
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier.padding(paddingValues)
|
||||
) {
|
||||
TextFields.TextField(
|
||||
value = searchEntry,
|
||||
onValueChange = onSearchEntryChanged,
|
||||
placeholder = { Text(text = stringResource(id = R.string.FindByActivity__search)) },
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
colors = TextFieldDefaults.colors(
|
||||
unfocusedIndicatorColor = Color.Transparent,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
disabledIndicatorColor = Color.Transparent,
|
||||
errorIndicatorColor = Color.Transparent,
|
||||
cursorColor = MaterialTheme.colorScheme.onSurface
|
||||
),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 10.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.heightIn(min = 44.dp),
|
||||
contentPadding = TextFieldDefaults.contentPaddingWithoutLabel(top = 10.dp, bottom = 10.dp)
|
||||
)
|
||||
|
||||
LazyColumn {
|
||||
items(
|
||||
items = supportedCountryPrefixes
|
||||
) {
|
||||
CountryPrefixRowItem(
|
||||
searchTerm = searchEntry,
|
||||
countryPrefix = it,
|
||||
onClick = { onCountryPrefixSelected(it) }
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
focusRequester.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun CountryPrefixRowItem(
|
||||
searchTerm: String,
|
||||
countryPrefix: CountryPrefix,
|
||||
onClick: () -> Unit
|
||||
) {
|
||||
val regionDisplayName = remember(countryPrefix.regionCode, Locale.current) {
|
||||
PhoneNumberFormatter.getRegionDisplayName(countryPrefix.regionCode).orElse(countryPrefix.regionCode)
|
||||
}
|
||||
|
||||
if (searchTerm.isNotBlank() && !regionDisplayName.contains(searchTerm, ignoreCase = true)) {
|
||||
return
|
||||
}
|
||||
|
||||
val highlightedName: AnnotatedString = remember(regionDisplayName, searchTerm) {
|
||||
if (searchTerm.isBlank()) {
|
||||
AnnotatedString(regionDisplayName)
|
||||
} else {
|
||||
buildAnnotatedString {
|
||||
append(regionDisplayName)
|
||||
|
||||
val startIndex = regionDisplayName.indexOf(searchTerm, ignoreCase = true)
|
||||
|
||||
addStyle(
|
||||
style = SpanStyle(
|
||||
fontWeight = FontWeight.Bold
|
||||
),
|
||||
start = startIndex,
|
||||
end = startIndex + searchTerm.length
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Column(
|
||||
verticalArrangement = spacedBy((-2).dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(onClick = onClick)
|
||||
.padding(horizontal = dimensionResource(id = R.dimen.core_ui__gutter))
|
||||
.padding(top = 16.dp, bottom = 14.dp)
|
||||
) {
|
||||
Text(
|
||||
text = highlightedName
|
||||
)
|
||||
|
||||
Text(
|
||||
text = countryPrefix.toString(),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
enum class FindByMode {
|
||||
PHONE_NUMBER,
|
||||
USERNAME
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
import org.thoughtcrime.securesms.recipients.RecipientId
|
||||
|
||||
sealed interface FindByResult {
|
||||
data class Success(val recipientId: RecipientId) : FindByResult
|
||||
object InvalidEntry : FindByResult
|
||||
data class NotFound(val recipientId: RecipientId = RecipientId.UNKNOWN) : FindByResult
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
|
||||
data class FindByState(
|
||||
val mode: FindByMode,
|
||||
val userEntry: String = "",
|
||||
val supportedCountryPrefixes: List<CountryPrefix> = PhoneNumberUtil.getInstance().supportedCallingCodes
|
||||
.map { CountryPrefix(it, PhoneNumberUtil.getInstance().getRegionCodeForCountryCode(it)) }
|
||||
.sortedBy { it.digits.toString() },
|
||||
val selectedCountryPrefix: CountryPrefix = supportedCountryPrefixes.first(),
|
||||
val countryPrefixSearchEntry: String = "",
|
||||
val isLookupInProgress: Boolean = false
|
||||
)
|
||||
@@ -0,0 +1,118 @@
|
||||
/*
|
||||
* Copyright 2024 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.recipients.ui.findby
|
||||
|
||||
import android.content.Context
|
||||
import androidx.annotation.WorkerThread
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.async
|
||||
import org.signal.core.util.concurrent.safeBlockingGet
|
||||
import org.thoughtcrime.securesms.contacts.sync.ContactDiscovery
|
||||
import org.thoughtcrime.securesms.phonenumbers.NumberUtil
|
||||
import org.thoughtcrime.securesms.profiles.manage.UsernameRepository
|
||||
import org.thoughtcrime.securesms.recipients.Recipient
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
import org.thoughtcrime.securesms.util.UsernameUtil
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class FindByViewModel(
|
||||
mode: FindByMode
|
||||
) : ViewModel() {
|
||||
|
||||
private val internalState = mutableStateOf(
|
||||
FindByState(
|
||||
mode = mode
|
||||
)
|
||||
)
|
||||
|
||||
val state: State<FindByState> = internalState
|
||||
|
||||
fun onUserEntryChanged(userEntry: String) {
|
||||
val cleansed = if (state.value.mode == FindByMode.PHONE_NUMBER) {
|
||||
userEntry.filter { it.isDigit() }
|
||||
} else {
|
||||
userEntry
|
||||
}
|
||||
|
||||
internalState.value = state.value.copy(userEntry = cleansed)
|
||||
}
|
||||
|
||||
fun onCountryPrefixSearchEntryChanged(searchEntry: String) {
|
||||
internalState.value = state.value.copy(countryPrefixSearchEntry = searchEntry)
|
||||
}
|
||||
|
||||
fun onCountryPrefixSelected(countryPrefix: CountryPrefix) {
|
||||
internalState.value = state.value.copy(selectedCountryPrefix = countryPrefix)
|
||||
}
|
||||
|
||||
suspend fun onNextClicked(context: Context): FindByResult {
|
||||
internalState.value = state.value.copy(isLookupInProgress = true)
|
||||
val findByResult = viewModelScope.async(context = Dispatchers.IO) {
|
||||
if (state.value.mode == FindByMode.USERNAME) {
|
||||
performUsernameLookup()
|
||||
} else {
|
||||
performPhoneLookup(context)
|
||||
}
|
||||
}.await()
|
||||
|
||||
internalState.value = state.value.copy(isLookupInProgress = false)
|
||||
return findByResult
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun performUsernameLookup(): FindByResult {
|
||||
val username = state.value.userEntry
|
||||
|
||||
if (!UsernameUtil.isValidUsernameForSearch(username)) {
|
||||
return FindByResult.InvalidEntry
|
||||
}
|
||||
|
||||
return when (val result = UsernameRepository.fetchAciForUsername(username = username).safeBlockingGet()) {
|
||||
UsernameRepository.UsernameAciFetchResult.NetworkError -> FindByResult.NotFound()
|
||||
UsernameRepository.UsernameAciFetchResult.NotFound -> FindByResult.NotFound()
|
||||
is UsernameRepository.UsernameAciFetchResult.Success -> FindByResult.Success(Recipient.externalUsername(result.aci, username).id)
|
||||
}
|
||||
}
|
||||
|
||||
@WorkerThread
|
||||
private fun performPhoneLookup(context: Context): FindByResult {
|
||||
val stateSnapshot = state.value
|
||||
val countryCode = stateSnapshot.selectedCountryPrefix.digits
|
||||
val nationalNumber = stateSnapshot.userEntry.removePrefix(countryCode.toString())
|
||||
|
||||
val e164 = "$countryCode$nationalNumber"
|
||||
|
||||
if (!NumberUtil.isVisuallyValidNumber(e164)) {
|
||||
return FindByResult.InvalidEntry
|
||||
}
|
||||
|
||||
val recipient = try {
|
||||
Recipient.external(context, e164)
|
||||
} catch (e: Exception) {
|
||||
return FindByResult.InvalidEntry
|
||||
}
|
||||
|
||||
return if (!recipient.isRegistered || !recipient.hasServiceId()) {
|
||||
try {
|
||||
ContactDiscovery.refresh(context, recipient, false, TimeUnit.SECONDS.toMillis(10))
|
||||
val resolved = Recipient.resolved(recipient.id)
|
||||
if (!resolved.isRegistered) {
|
||||
FindByResult.NotFound(recipient.id)
|
||||
} else {
|
||||
FindByResult.Success(recipient.id)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
FindByResult.NotFound(recipient.id)
|
||||
}
|
||||
} else {
|
||||
FindByResult.Success(recipient.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user