mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-02-27 21:24:42 +00:00
Add new country picker for registration.
Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.registration.data.network.Challenge
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
import org.whispersystems.signalservice.api.svr.Svr3Credentials
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
import kotlin.time.Duration
|
||||
@@ -52,7 +53,8 @@ data class RegistrationState(
|
||||
val networkError: Throwable? = null,
|
||||
val sessionCreationError: RegistrationSessionResult? = null,
|
||||
val sessionStateError: VerificationCodeRequestResult? = null,
|
||||
val registerAccountError: RegisterAccountResult? = null
|
||||
val registerAccountError: RegisterAccountResult? = null,
|
||||
val country: Country? = null
|
||||
) {
|
||||
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
|
||||
|
||||
|
||||
@@ -61,6 +61,7 @@ import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequ
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.Success
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.TokenNotAccepted
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult.UnknownError
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
@@ -194,6 +195,18 @@ class RegistrationViewModel : ViewModel() {
|
||||
}
|
||||
}
|
||||
|
||||
fun setCurrentCountryPicked(country: Country) {
|
||||
store.update {
|
||||
it.copy(country = country)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCountry() {
|
||||
store.update {
|
||||
it.copy(country = null)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchFcmToken(context: Context) {
|
||||
viewModelScope.launch(context = coroutineExceptionHandler) {
|
||||
val fcmToken = RegistrationRepository.getFcmToken(context)
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.countrycode
|
||||
|
||||
/**
|
||||
* Data class string describing useful characteristics of countries when selecting one. Used in the [CountryCodeState]
|
||||
*/
|
||||
data class Country(val emoji: String, val name: String, val countryCode: String)
|
||||
@@ -0,0 +1,297 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.countrycode
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
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.VisualTransformation
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
|
||||
|
||||
/**
|
||||
* Country picker fragment used in registration V1
|
||||
*/
|
||||
class CountryCodeFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CountryCodeFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: CountryCodeViewModel by viewModels()
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Screen(
|
||||
state = state,
|
||||
onSearch = { search -> viewModel.filterCountries(search) },
|
||||
onDismissed = { findNavController().popBackStack() },
|
||||
onClick = { country ->
|
||||
sharedViewModel.setCurrentCountryPicked(country)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel.loadCountries()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun Screen(
|
||||
state: CountryCodeState,
|
||||
onSearch: (String) -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
onClick: (Country) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Scaffolds.DefaultTopAppBar(
|
||||
title = stringResource(R.string.CountryCodeFragment__your_country),
|
||||
titleContent = { _, title ->
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||
},
|
||||
onNavigationClick = onDismissed,
|
||||
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(R.drawable.symbol_x_24))
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
item {
|
||||
SearchBar(
|
||||
text = state.query,
|
||||
onSearch = onSearch
|
||||
)
|
||||
Spacer(modifier = Modifier.size(18.dp))
|
||||
}
|
||||
|
||||
if (state.countryList.isEmpty()) {
|
||||
item {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
}
|
||||
} else if (state.query.isEmpty()) {
|
||||
items(state.commonCountryList) { country ->
|
||||
CountryItem(country, onClick)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
items(state.countryList) { country ->
|
||||
CountryItem(country, onClick)
|
||||
}
|
||||
} else {
|
||||
items(state.filteredList) { country ->
|
||||
CountryItem(country, onClick, state.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CountryItem(
|
||||
country: Country,
|
||||
onClick: (Country) -> Unit = {},
|
||||
query: String = ""
|
||||
) {
|
||||
val emoji = country.emoji
|
||||
val name = country.name
|
||||
val code = country.countryCode
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 56.dp)
|
||||
.clickable { onClick(country) }
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = name.ifEmpty { stringResource(R.string.CountryCodeFragment__unknown_country) },
|
||||
modifier = Modifier
|
||||
.padding(start = 24.dp)
|
||||
.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = code,
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
val annotatedName = buildAnnotatedString {
|
||||
val startIndex = name.indexOf(query, ignoreCase = true)
|
||||
|
||||
if (startIndex >= 0) {
|
||||
append(name.substring(0, startIndex))
|
||||
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(name.substring(startIndex, startIndex + query.length))
|
||||
}
|
||||
|
||||
append(name.substring(startIndex + query.length))
|
||||
} else {
|
||||
append(name)
|
||||
}
|
||||
}
|
||||
|
||||
val annotatedCode = buildAnnotatedString {
|
||||
val startIndex = code.indexOf(query, ignoreCase = true)
|
||||
|
||||
if (startIndex >= 0) {
|
||||
append(code.substring(0, startIndex))
|
||||
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(code.substring(startIndex, startIndex + query.length))
|
||||
}
|
||||
|
||||
append(code.substring(startIndex + query.length))
|
||||
} else {
|
||||
append(code)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotatedName,
|
||||
modifier = Modifier
|
||||
.padding(start = 24.dp)
|
||||
.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = annotatedCode,
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
hint: String = stringResource(R.string.CountryCodeFragment__search_by),
|
||||
onSearch: (String) -> Unit = {}
|
||||
) {
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = { onSearch(it) },
|
||||
placeholder = { Text(hint) },
|
||||
// TODO(michelle): Add keyboard switch to dialpad
|
||||
// trailingIcon = {
|
||||
// Icon(
|
||||
// imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24),
|
||||
// contentDescription = "Search icon"
|
||||
// )
|
||||
// },
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(54.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = TextFieldDefaults.colors(
|
||||
// TODO move to SignalTheme
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ScreenPreview() {
|
||||
Previews.Preview {
|
||||
Screen(
|
||||
state = CountryCodeState(
|
||||
countryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+1"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+2"),
|
||||
Country("\uD83C\uDDF2\uD83C\uDDFD", "Mexico", "+3")
|
||||
),
|
||||
commonCountryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+4"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+5")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun LoadingScreenPreview() {
|
||||
Previews.Preview {
|
||||
Screen(
|
||||
state = CountryCodeState(
|
||||
countryList = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.countrycode
|
||||
|
||||
/**
|
||||
* State managed by [CountryCodeViewModel]. Includes country list and allows for searching
|
||||
*/
|
||||
data class CountryCodeState(
|
||||
val query: String = "",
|
||||
val countryList: List<Country> = emptyList(),
|
||||
val commonCountryList: List<Country> = emptyList(),
|
||||
val filteredList: List<Country> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,97 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.countrycode
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* View model to support [CountryCodeFragment] and track the countries
|
||||
*/
|
||||
class CountryCodeViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CountryCodeViewModel::class.java)
|
||||
}
|
||||
|
||||
private val internalState = MutableStateFlow(CountryCodeState())
|
||||
val state = internalState.asStateFlow()
|
||||
|
||||
fun filterCountries(filterBy: String) {
|
||||
if (filterBy.isEmpty()) {
|
||||
internalState.update {
|
||||
it.copy(
|
||||
query = filterBy,
|
||||
filteredList = emptyList()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
internalState.update {
|
||||
it.copy(
|
||||
query = filterBy,
|
||||
filteredList = state.value.countryList.filter { country: Country ->
|
||||
country.name.contains(filterBy, ignoreCase = true) || country.countryCode.contains(filterBy)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCountries() {
|
||||
loadCommonCountryList()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val regions = PhoneNumberUtil.getInstance().supportedRegions
|
||||
val countries = mutableListOf<Country>()
|
||||
|
||||
for (region in regions) {
|
||||
val c = Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(region),
|
||||
countryCode = "+" + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region)
|
||||
)
|
||||
countries.add(c)
|
||||
}
|
||||
|
||||
val sortedCountries = countries.sortedWith { lhs, rhs ->
|
||||
lhs.name.lowercase(Locale.getDefault()).compareTo(rhs.name.lowercase(Locale.getDefault()))
|
||||
}
|
||||
|
||||
internalState.update {
|
||||
it.copy(
|
||||
countryList = sortedCountries
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCommonCountryList() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val countries = mutableListOf<Country>()
|
||||
for (region in CountryUtils.COMMON_COUNTRIES) {
|
||||
val c = Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(region),
|
||||
countryCode = "+" + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region)
|
||||
)
|
||||
countries.add(c)
|
||||
}
|
||||
internalState.update {
|
||||
it.copy(
|
||||
commonCountryList = countries
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.countrycode
|
||||
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* Utility functions used when working with countries
|
||||
*/
|
||||
object CountryUtils {
|
||||
|
||||
fun countryToEmoji(countryCode: String): String {
|
||||
return if (countryCode.isNotEmpty()) {
|
||||
countryCode
|
||||
.uppercase(Locale.US)
|
||||
.map { char -> Character.codePointAt("$char", 0) - 0x41 + 0x1F1E6 }
|
||||
.map { codePoint -> Character.toChars(codePoint) }
|
||||
.joinToString(separator = "") { charArray -> String(charArray) }
|
||||
} else {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/** A hardcoded list of countries to suggest during registration. Can change at any time. */
|
||||
val COMMON_COUNTRIES = listOf("US", "DE", "IN", "NL", "UA")
|
||||
}
|
||||
@@ -31,7 +31,6 @@ import androidx.navigation.fragment.findNavController
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.i18n.phonenumbers.AsYouTypeFormatter
|
||||
import com.google.i18n.phonenumbers.NumberParseException
|
||||
@@ -58,6 +57,7 @@ import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegat
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationCheckpoint
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationState
|
||||
import org.thoughtcrime.securesms.registration.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
import org.thoughtcrime.securesms.registration.ui.toE164
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
@@ -85,7 +85,8 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
|
||||
private lateinit var spinnerAdapter: ArrayAdapter<CountryPrefix>
|
||||
private lateinit var phoneNumberInputLayout: TextInputEditText
|
||||
private lateinit var spinnerView: MaterialAutoCompleteTextView
|
||||
private lateinit var spinnerView: TextInputEditText
|
||||
private lateinit var countryPickerView: View
|
||||
|
||||
private var currentPhoneNumberFormatter: AsYouTypeFormatter? = null
|
||||
|
||||
@@ -101,7 +102,13 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
}
|
||||
)
|
||||
phoneNumberInputLayout = binding.number.editText as TextInputEditText
|
||||
spinnerView = binding.countryCode.editText as MaterialAutoCompleteTextView
|
||||
spinnerView = binding.countryCode.editText as TextInputEditText
|
||||
countryPickerView = binding.countryPicker
|
||||
|
||||
countryPickerView.setOnClickListener {
|
||||
moveToCountryPickerScreen()
|
||||
}
|
||||
|
||||
spinnerAdapter = ArrayAdapter<CountryPrefix>(
|
||||
requireContext(),
|
||||
R.layout.registration_country_code_dropdown_item,
|
||||
@@ -118,6 +125,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState ->
|
||||
presentRegisterButton(sharedState)
|
||||
updateEnabledControls(sharedState.inProgress, sharedState.isReRegister)
|
||||
updateCountrySelection(sharedState.country)
|
||||
|
||||
sharedState.networkError?.let {
|
||||
presentNetworkError(it)
|
||||
@@ -167,6 +175,8 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
sharedViewModel.setPhoneNumber(null)
|
||||
}
|
||||
|
||||
updateCountrySelection(fragmentState.country)
|
||||
|
||||
if (fragmentState.error != EnterPhoneNumberState.Error.NONE) {
|
||||
presentLocalError(fragmentState)
|
||||
}
|
||||
@@ -186,6 +196,15 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout)
|
||||
}
|
||||
|
||||
private fun updateCountrySelection(country: Country?) {
|
||||
if (country != null) {
|
||||
binding.countryEmoji.text = country.emoji
|
||||
binding.country.text = country.name
|
||||
binding.countryCode.editText?.setText(country.countryCode)
|
||||
}
|
||||
sharedViewModel.clearCountry()
|
||||
}
|
||||
|
||||
private fun reformatText(text: Editable?) {
|
||||
if (text.isNullOrEmpty()) {
|
||||
return
|
||||
@@ -220,6 +239,12 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
|
||||
private fun initializeInputFields() {
|
||||
binding.countryCode.editText?.addTextChangedListener { s ->
|
||||
if (s.isNotNullOrBlank()) {
|
||||
val formatted = s.toString().replace("+", "").let { "+$it" }
|
||||
if (formatted != s.toString()) {
|
||||
s.replace(0, s.length, formatted)
|
||||
}
|
||||
}
|
||||
val sanitized = s.toString().filter { c -> c.isDigit() }
|
||||
if (sanitized.isNotNullOrBlank()) {
|
||||
val countryCode: Int = sanitized.toInt()
|
||||
@@ -252,26 +277,6 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
spinnerView.threshold = 100
|
||||
spinnerView.setAdapter(spinnerAdapter)
|
||||
spinnerView.addTextChangedListener(afterTextChanged = ::onCountryDropDownChanged)
|
||||
}
|
||||
|
||||
private fun onCountryDropDownChanged(s: Editable?) {
|
||||
if (s.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (s[0] != '+') {
|
||||
s.insert(0, "+")
|
||||
}
|
||||
|
||||
fragmentViewModel.supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let {
|
||||
fragmentViewModel.setCountry(it.digits)
|
||||
val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0
|
||||
phoneNumberInputLayout.setSelection(numberLength, numberLength)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentRegisterButton(sharedState: RegistrationState) {
|
||||
@@ -648,6 +653,10 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
sharedViewModel.setInProgress(false)
|
||||
}
|
||||
|
||||
private fun moveToCountryPickerScreen() {
|
||||
findNavController().safeNavigate(R.id.action_countryPicker)
|
||||
}
|
||||
|
||||
private fun popBackStack() {
|
||||
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION)
|
||||
findNavController().popBackStack()
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.registration.ui.phonenumber
|
||||
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
|
||||
/**
|
||||
* State holder for the phone number entry screen, including phone number and Play Services errors.
|
||||
@@ -15,7 +16,8 @@ data class EnterPhoneNumberState(
|
||||
val phoneNumber: String = "",
|
||||
val phoneNumberRegionCode: String,
|
||||
val mode: RegistrationRepository.E164VerificationMode = RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER,
|
||||
val error: Error = Error.NONE
|
||||
val error: Error = Error.NONE,
|
||||
val country: Country? = null
|
||||
) {
|
||||
enum class Error {
|
||||
NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT
|
||||
|
||||
@@ -14,7 +14,10 @@ import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.registration.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter
|
||||
|
||||
/**
|
||||
* ViewModel for the phone number entry screen.
|
||||
@@ -69,7 +72,12 @@ class EnterPhoneNumberViewModel : ViewModel() {
|
||||
store.update {
|
||||
it.copy(
|
||||
countryPrefixIndex = matchingIndex,
|
||||
phoneNumberRegionCode = supportedCountryPrefixes[matchingIndex].regionCode
|
||||
phoneNumberRegionCode = supportedCountryPrefixes[matchingIndex].regionCode,
|
||||
country = Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(supportedCountryPrefixes[matchingIndex].regionCode).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(supportedCountryPrefixes[matchingIndex].regionCode),
|
||||
countryCode = digits.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ import org.thoughtcrime.securesms.registration.data.network.Challenge
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegisterAccountResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country
|
||||
import org.whispersystems.signalservice.api.svr.Svr3Credentials
|
||||
import org.whispersystems.signalservice.internal.push.AuthCredentials
|
||||
import kotlin.time.Duration
|
||||
@@ -53,7 +54,8 @@ data class RegistrationState(
|
||||
val networkError: Throwable? = null,
|
||||
val sessionCreationError: RegistrationSessionResult? = null,
|
||||
val sessionStateError: VerificationCodeRequestResult? = null,
|
||||
val registerAccountError: RegisterAccountResult? = null
|
||||
val registerAccountError: RegisterAccountResult? = null,
|
||||
val country: Country? = null
|
||||
) {
|
||||
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
|
||||
|
||||
|
||||
@@ -67,6 +67,7 @@ import org.thoughtcrime.securesms.registration.ui.toE164
|
||||
import org.thoughtcrime.securesms.registration.util.RegistrationUtil
|
||||
import org.thoughtcrime.securesms.registration.viewmodel.SvrAuthCredentialSet
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country
|
||||
import org.thoughtcrime.securesms.util.RemoteConfig
|
||||
import org.thoughtcrime.securesms.util.Util
|
||||
import org.thoughtcrime.securesms.util.dualsim.MccMncProducer
|
||||
@@ -987,6 +988,18 @@ class RegistrationViewModel : ViewModel() {
|
||||
.firstOrNull()
|
||||
}
|
||||
|
||||
fun setCurrentCountryPicked(country: Country) {
|
||||
store.update {
|
||||
it.copy(country = country)
|
||||
}
|
||||
}
|
||||
|
||||
fun clearCountry() {
|
||||
store.update {
|
||||
it.copy(country = null)
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(RegistrationViewModel::class.java)
|
||||
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.countrycode
|
||||
|
||||
/**
|
||||
* Data class string describing useful characteristics of countries when selecting one. Used in the [CountryCodeState]
|
||||
*/
|
||||
data class Country(val emoji: String, val name: String, val countryCode: String)
|
||||
@@ -0,0 +1,298 @@
|
||||
@file:OptIn(ExperimentalMaterial3Api::class)
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.countrycode
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.defaultMinSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
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.material3.CircularProgressIndicator
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.graphics.vector.rememberVectorPainter
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.res.vectorResource
|
||||
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.VisualTransformation
|
||||
import androidx.compose.ui.text.withStyle
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.fragment.app.activityViewModels
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.R
|
||||
import org.thoughtcrime.securesms.compose.ComposeFragment
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
|
||||
/**
|
||||
* Country picker fragment used in registration V3
|
||||
*/
|
||||
class CountryCodeFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CountryCodeFragment::class.java)
|
||||
}
|
||||
|
||||
private val viewModel: CountryCodeViewModel by viewModels()
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
val state by viewModel.state.collectAsStateWithLifecycle()
|
||||
|
||||
Screen(
|
||||
state = state,
|
||||
onSearch = { search -> viewModel.filterCountries(search) },
|
||||
onDismissed = { findNavController().popBackStack() },
|
||||
onClick = { country ->
|
||||
sharedViewModel.setCurrentCountryPicked(country)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel.loadCountries()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun Screen(
|
||||
state: CountryCodeState,
|
||||
onSearch: (String) -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
onClick: (Country) -> Unit = {}
|
||||
) {
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Scaffolds.DefaultTopAppBar(
|
||||
title = stringResource(R.string.CountryCodeFragment__your_country),
|
||||
titleContent = { _, title ->
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||
},
|
||||
onNavigationClick = onDismissed,
|
||||
navigationIconPainter = rememberVectorPainter(ImageVector.vectorResource(R.drawable.symbol_x_24))
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
LazyColumn(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
item {
|
||||
SearchBar(
|
||||
text = state.query,
|
||||
onSearch = onSearch
|
||||
)
|
||||
Spacer(modifier = Modifier.size(18.dp))
|
||||
}
|
||||
|
||||
if (state.countryList.isEmpty()) {
|
||||
item {
|
||||
CircularProgressIndicator(
|
||||
modifier = Modifier.size(56.dp)
|
||||
)
|
||||
}
|
||||
} else if (state.query.isEmpty()) {
|
||||
items(state.commonCountryList) { country ->
|
||||
CountryItem(country, onClick)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
|
||||
items(state.countryList) { country ->
|
||||
CountryItem(country, onClick)
|
||||
}
|
||||
} else {
|
||||
items(state.filteredList) { country ->
|
||||
CountryItem(country, onClick, state.query)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun CountryItem(
|
||||
country: Country,
|
||||
onClick: (Country) -> Unit = {},
|
||||
query: String = ""
|
||||
) {
|
||||
val emoji = country.emoji
|
||||
val name = country.name
|
||||
val code = country.countryCode
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
.padding(horizontal = 24.dp)
|
||||
.fillMaxWidth()
|
||||
.defaultMinSize(minHeight = 56.dp)
|
||||
.clickable { onClick(country) }
|
||||
) {
|
||||
Text(
|
||||
text = emoji,
|
||||
modifier = Modifier.size(24.dp)
|
||||
)
|
||||
|
||||
if (query.isEmpty()) {
|
||||
Text(
|
||||
text = name.ifEmpty { stringResource(R.string.CountryCodeFragment__unknown_country) },
|
||||
modifier = Modifier
|
||||
.padding(start = 24.dp)
|
||||
.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = code,
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
} else {
|
||||
val annotatedName = buildAnnotatedString {
|
||||
val startIndex = name.indexOf(query, ignoreCase = true)
|
||||
|
||||
if (startIndex >= 0) {
|
||||
append(name.substring(0, startIndex))
|
||||
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(name.substring(startIndex, startIndex + query.length))
|
||||
}
|
||||
|
||||
append(name.substring(startIndex + query.length))
|
||||
} else {
|
||||
append(name)
|
||||
}
|
||||
}
|
||||
|
||||
val annotatedCode = buildAnnotatedString {
|
||||
val startIndex = code.indexOf(query, ignoreCase = true)
|
||||
|
||||
if (startIndex >= 0) {
|
||||
append(code.substring(0, startIndex))
|
||||
|
||||
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
|
||||
append(code.substring(startIndex, startIndex + query.length))
|
||||
}
|
||||
|
||||
append(code.substring(startIndex + query.length))
|
||||
} else {
|
||||
append(code)
|
||||
}
|
||||
}
|
||||
|
||||
Text(
|
||||
text = annotatedName,
|
||||
modifier = Modifier
|
||||
.padding(start = 24.dp)
|
||||
.weight(1f),
|
||||
style = MaterialTheme.typography.bodyLarge
|
||||
)
|
||||
Text(
|
||||
text = annotatedCode,
|
||||
modifier = Modifier.padding(start = 24.dp),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun SearchBar(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
hint: String = stringResource(R.string.CountryCodeFragment__search_by),
|
||||
onSearch: (String) -> Unit = {}
|
||||
) {
|
||||
TextField(
|
||||
value = text,
|
||||
onValueChange = { onSearch(it) },
|
||||
placeholder = { Text(hint) },
|
||||
trailingIcon = {
|
||||
// TODO(michelle): Add keyboard switch to dialpad
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24),
|
||||
contentDescription = "Search icon"
|
||||
)
|
||||
},
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(54.dp)
|
||||
.padding(horizontal = 16.dp),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = TextFieldDefaults.colors(
|
||||
// TODO move to SignalTheme
|
||||
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
|
||||
focusedIndicatorColor = Color.Transparent,
|
||||
unfocusedIndicatorColor = Color.Transparent
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun ScreenPreview() {
|
||||
Previews.Preview {
|
||||
Screen(
|
||||
state = CountryCodeState(
|
||||
countryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+1"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+2"),
|
||||
Country("\uD83C\uDDF2\uD83C\uDDFD", "Mexico", "+3")
|
||||
),
|
||||
commonCountryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+4"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+5")
|
||||
)
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@SignalPreview
|
||||
@Composable
|
||||
private fun LoadingScreenPreview() {
|
||||
Previews.Preview {
|
||||
Screen(
|
||||
state = CountryCodeState(
|
||||
countryList = emptyList()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.countrycode
|
||||
|
||||
/**
|
||||
* State managed by [CountryCodeViewModel]. Includes country list and allows for searching
|
||||
*/
|
||||
data class CountryCodeState(
|
||||
val query: String = "",
|
||||
val countryList: List<Country> = emptyList(),
|
||||
val commonCountryList: List<Country> = emptyList(),
|
||||
val filteredList: List<Country> = emptyList()
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.countrycode
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
* View model to support [CountryCodeFragment] and track the countries
|
||||
*/
|
||||
class CountryCodeViewModel : ViewModel() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CountryCodeViewModel::class.java)
|
||||
}
|
||||
|
||||
private val internalState = MutableStateFlow(CountryCodeState())
|
||||
val state = internalState.asStateFlow()
|
||||
|
||||
fun filterCountries(filterBy: String) {
|
||||
if (filterBy.isEmpty()) {
|
||||
internalState.update {
|
||||
it.copy(
|
||||
query = filterBy,
|
||||
filteredList = emptyList()
|
||||
)
|
||||
}
|
||||
} else {
|
||||
internalState.update {
|
||||
it.copy(
|
||||
query = filterBy,
|
||||
filteredList = state.value.countryList.filter { country: Country ->
|
||||
country.name.contains(filterBy, ignoreCase = true) || country.countryCode.contains(filterBy)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCountries() {
|
||||
loadCommonCountryList()
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val regions = PhoneNumberUtil.getInstance().supportedRegions
|
||||
val countries = mutableListOf<Country>()
|
||||
|
||||
for (region in regions) {
|
||||
val c = Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(region),
|
||||
countryCode = "+" + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region)
|
||||
)
|
||||
countries.add(c)
|
||||
}
|
||||
|
||||
val sortedCountries = countries.sortedWith { lhs, rhs ->
|
||||
lhs.name.lowercase(Locale.getDefault()).compareTo(rhs.name.lowercase(Locale.getDefault()))
|
||||
}
|
||||
|
||||
internalState.update {
|
||||
it.copy(
|
||||
countryList = sortedCountries
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun loadCommonCountryList() {
|
||||
viewModelScope.launch(Dispatchers.IO) {
|
||||
val countries = mutableListOf<Country>()
|
||||
for (region in CountryUtils.COMMON_COUNTRIES) {
|
||||
val c = Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(region),
|
||||
countryCode = "+" + PhoneNumberUtil.getInstance().getCountryCodeForRegion(region)
|
||||
)
|
||||
countries.add(c)
|
||||
}
|
||||
internalState.update {
|
||||
it.copy(
|
||||
commonCountryList = countries
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,6 @@ import androidx.navigation.fragment.navArgs
|
||||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder
|
||||
import com.google.android.material.textfield.MaterialAutoCompleteTextView
|
||||
import com.google.android.material.textfield.TextInputEditText
|
||||
import com.google.i18n.phonenumbers.AsYouTypeFormatter
|
||||
import com.google.i18n.phonenumbers.NumberParseException
|
||||
@@ -61,6 +60,7 @@ import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationCheckpoint
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationState
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.RegistrationViewModel
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
import org.thoughtcrime.securesms.util.Dialogs
|
||||
import org.thoughtcrime.securesms.util.PlayServicesUtil
|
||||
@@ -90,7 +90,8 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
|
||||
private lateinit var spinnerAdapter: ArrayAdapter<CountryPrefix>
|
||||
private lateinit var phoneNumberInputLayout: TextInputEditText
|
||||
private lateinit var spinnerView: MaterialAutoCompleteTextView
|
||||
private lateinit var spinnerView: TextInputEditText
|
||||
private lateinit var countryPickerView: View
|
||||
|
||||
private var currentPhoneNumberFormatter: AsYouTypeFormatter? = null
|
||||
|
||||
@@ -106,7 +107,13 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
}
|
||||
)
|
||||
phoneNumberInputLayout = binding.number.editText as TextInputEditText
|
||||
spinnerView = binding.countryCode.editText as MaterialAutoCompleteTextView
|
||||
spinnerView = binding.countryCode.editText as TextInputEditText
|
||||
countryPickerView = binding.countryPicker
|
||||
|
||||
countryPickerView.setOnClickListener {
|
||||
moveToCountryPickerScreen()
|
||||
}
|
||||
|
||||
spinnerAdapter = ArrayAdapter<CountryPrefix>(
|
||||
requireContext(),
|
||||
R.layout.registration_country_code_dropdown_item,
|
||||
@@ -123,6 +130,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
sharedViewModel.uiState.observe(viewLifecycleOwner) { sharedState ->
|
||||
presentRegisterButton(sharedState)
|
||||
updateEnabledControls(sharedState.inProgress, sharedState.isReRegister)
|
||||
updateCountrySelection(sharedState.country)
|
||||
|
||||
sharedState.networkError?.let {
|
||||
presentNetworkError(it)
|
||||
@@ -172,6 +180,8 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
sharedViewModel.setPhoneNumber(null)
|
||||
}
|
||||
|
||||
updateCountrySelection(fragmentState.country)
|
||||
|
||||
if (fragmentState.error != EnterPhoneNumberState.Error.NONE) {
|
||||
presentLocalError(fragmentState)
|
||||
}
|
||||
@@ -196,6 +206,15 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
}
|
||||
}
|
||||
|
||||
private fun updateCountrySelection(country: Country?) {
|
||||
if (country != null) {
|
||||
binding.countryEmoji.text = country.emoji
|
||||
binding.country.text = country.name
|
||||
binding.countryCode.editText?.setText(country.countryCode)
|
||||
}
|
||||
sharedViewModel.clearCountry()
|
||||
}
|
||||
|
||||
private fun reformatText(text: Editable?) {
|
||||
if (text.isNullOrEmpty()) {
|
||||
return
|
||||
@@ -230,6 +249,12 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
|
||||
private fun initializeInputFields() {
|
||||
binding.countryCode.editText?.addTextChangedListener { s ->
|
||||
if (s.isNotNullOrBlank()) {
|
||||
val formatted = s.toString().replace("+", "").let { "+$it" }
|
||||
if (formatted != s.toString()) {
|
||||
s.replace(0, s.length, formatted)
|
||||
}
|
||||
}
|
||||
val sanitized = s.toString().filter { c -> c.isDigit() }
|
||||
if (sanitized.isNotNullOrBlank()) {
|
||||
val countryCode: Int = sanitized.toInt()
|
||||
@@ -262,26 +287,6 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
spinnerView.threshold = 100
|
||||
spinnerView.setAdapter(spinnerAdapter)
|
||||
spinnerView.addTextChangedListener(afterTextChanged = ::onCountryDropDownChanged)
|
||||
}
|
||||
|
||||
private fun onCountryDropDownChanged(s: Editable?) {
|
||||
if (s.isNullOrEmpty()) {
|
||||
return
|
||||
}
|
||||
|
||||
if (s[0] != '+') {
|
||||
s.insert(0, "+")
|
||||
}
|
||||
|
||||
fragmentViewModel.supportedCountryPrefixes.firstOrNull { it.toString() == s.toString() }?.let {
|
||||
fragmentViewModel.setCountry(it.digits)
|
||||
val numberLength: Int = phoneNumberInputLayout.text?.length ?: 0
|
||||
phoneNumberInputLayout.setSelection(numberLength, numberLength)
|
||||
}
|
||||
}
|
||||
|
||||
private fun presentRegisterButton(sharedState: RegistrationState) {
|
||||
@@ -667,6 +672,10 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
sharedViewModel.setInProgress(false)
|
||||
}
|
||||
|
||||
private fun moveToCountryPickerScreen() {
|
||||
findNavController().safeNavigate(R.id.action_countryPicker)
|
||||
}
|
||||
|
||||
private fun popBackStack() {
|
||||
sharedViewModel.setRegistrationCheckpoint(RegistrationCheckpoint.INITIALIZATION)
|
||||
findNavController().popBackStack()
|
||||
@@ -687,13 +696,11 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
menuInflater.inflate(R.menu.enter_phone_number, menu)
|
||||
}
|
||||
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
|
||||
return if (menuItem.itemId == R.id.phone_menu_use_proxy) {
|
||||
NavHostFragment.findNavController(this@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy())
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
override fun onMenuItemSelected(menuItem: MenuItem): Boolean = if (menuItem.itemId == R.id.phone_menu_use_proxy) {
|
||||
NavHostFragment.findNavController(this@EnterPhoneNumberFragment).safeNavigate(EnterPhoneNumberFragmentDirections.actionEditProxy())
|
||||
true
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
|
||||
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country
|
||||
|
||||
/**
|
||||
* State holder for the phone number entry screen, including phone number and Play Services errors.
|
||||
@@ -15,7 +16,8 @@ data class EnterPhoneNumberState(
|
||||
val phoneNumber: String = "",
|
||||
val phoneNumberRegionCode: String,
|
||||
val mode: RegistrationRepository.E164VerificationMode = RegistrationRepository.E164VerificationMode.SMS_WITHOUT_LISTENER,
|
||||
val error: Error = Error.NONE
|
||||
val error: Error = Error.NONE,
|
||||
val country: Country? = null
|
||||
) {
|
||||
enum class Error {
|
||||
NONE, INVALID_PHONE_NUMBER, PLAY_SERVICES_MISSING, PLAY_SERVICES_NEEDS_UPDATE, PLAY_SERVICES_TRANSIENT
|
||||
|
||||
@@ -13,8 +13,11 @@ import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
import org.thoughtcrime.securesms.registrationv3.data.RegistrationRepository
|
||||
import org.thoughtcrime.securesms.registrationv3.ui.countrycode.Country
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter
|
||||
|
||||
/**
|
||||
* ViewModel for the phone number entry screen.
|
||||
@@ -51,9 +54,7 @@ class EnterPhoneNumberViewModel : ViewModel() {
|
||||
it.copy(mode = value)
|
||||
}
|
||||
|
||||
fun countryPrefix(): CountryPrefix {
|
||||
return supportedCountryPrefixes[store.value.countryPrefixIndex]
|
||||
}
|
||||
fun countryPrefix(): CountryPrefix = supportedCountryPrefixes[store.value.countryPrefixIndex]
|
||||
|
||||
fun setPhoneNumber(phoneNumber: String?) {
|
||||
store.update { it.copy(phoneNumber = phoneNumber ?: "") }
|
||||
@@ -69,7 +70,12 @@ class EnterPhoneNumberViewModel : ViewModel() {
|
||||
store.update {
|
||||
it.copy(
|
||||
countryPrefixIndex = matchingIndex,
|
||||
phoneNumberRegionCode = supportedCountryPrefixes[matchingIndex].regionCode
|
||||
phoneNumberRegionCode = supportedCountryPrefixes[matchingIndex].regionCode,
|
||||
country = Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(supportedCountryPrefixes[matchingIndex].regionCode).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(supportedCountryPrefixes[matchingIndex].regionCode),
|
||||
countryCode = digits.toString()
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user