Add new country picker for registration.

Co-authored-by: Greyson Parrelli <greyson@signal.org>
This commit is contained in:
Michelle Tang
2025-01-31 18:56:24 -05:00
committed by GitHub
parent 0a90d9f003
commit 2ad04b1e88
24 changed files with 1124 additions and 80 deletions

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()
)
)
}
}

View File

@@ -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()
)

View File

@@ -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
)
}
}
}
}

View File

@@ -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")
}

View File

@@ -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()

View File

@@ -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

View File

@@ -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()
)
)
}
}

View File

@@ -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 }

View File

@@ -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)

View File

@@ -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)

View File

@@ -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()
)
)
}
}

View File

@@ -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()
)

View File

@@ -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
)
}
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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

View File

@@ -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()
)
)
}
}