mirror of
https://github.com/signalapp/Signal-Android.git
synced 2025-12-25 05:27:42 +00:00
Add more fixes to the country picker.
This commit is contained in:
@@ -14,7 +14,6 @@ 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
|
||||
@@ -53,8 +52,7 @@ data class RegistrationState(
|
||||
val networkError: Throwable? = null,
|
||||
val sessionCreationError: RegistrationSessionResult? = null,
|
||||
val sessionStateError: VerificationCodeRequestResult? = null,
|
||||
val registerAccountError: RegisterAccountResult? = null,
|
||||
val country: Country? = null
|
||||
val registerAccountError: RegisterAccountResult? = null
|
||||
) {
|
||||
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
|
||||
|
||||
|
||||
@@ -61,7 +61,6 @@ 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
|
||||
@@ -113,9 +112,6 @@ class RegistrationViewModel : ViewModel() {
|
||||
val phoneNumber: Phonenumber.PhoneNumber?
|
||||
get() = store.value.phoneNumber
|
||||
|
||||
val country: Country?
|
||||
get() = store.value.country
|
||||
|
||||
fun maybePrefillE164(context: Context) {
|
||||
Log.v(TAG, "maybePrefillE164()")
|
||||
if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) {
|
||||
@@ -198,18 +194,6 @@ 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)
|
||||
|
||||
@@ -5,7 +5,12 @@
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.countrycode
|
||||
|
||||
import android.os.Parcelable
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
/**
|
||||
* Data class string describing useful characteristics of countries when selecting one. Used in the [CountryCodeState]
|
||||
* An example is: Country(emoji=🇺🇸, name = "United States", countryCode = 1, regionCode= "US")
|
||||
*/
|
||||
data class Country(val emoji: String, val name: String, val countryCode: String)
|
||||
@Parcelize
|
||||
data class Country(val emoji: String, val name: String, val countryCode: Int, val regionCode: String) : Parcelable
|
||||
|
||||
@@ -4,9 +4,10 @@ package org.thoughtcrime.securesms.registration.ui.countrycode
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
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
|
||||
@@ -14,6 +15,7 @@ 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.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -25,9 +27,11 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -45,19 +49,21 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
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.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.IconButtons.IconButton
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
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
|
||||
@@ -66,10 +72,12 @@ class CountryCodeFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CountryCodeFragment::class.java)
|
||||
const val REQUEST_KEY_COUNTRY = "request_key_country"
|
||||
const val REQUEST_COUNTRY = "country"
|
||||
const val RESULT_COUNTRY = "country"
|
||||
}
|
||||
|
||||
private val viewModel: CountryCodeViewModel by viewModels()
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
@@ -77,10 +85,16 @@ class CountryCodeFragment : ComposeFragment() {
|
||||
|
||||
Screen(
|
||||
state = state,
|
||||
title = stringResource(R.string.CountryCodeFragment__your_country),
|
||||
onSearch = { search -> viewModel.filterCountries(search) },
|
||||
onDismissed = { findNavController().popBackStack() },
|
||||
onClick = { country ->
|
||||
sharedViewModel.setCurrentCountryPicked(country)
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_COUNTRY,
|
||||
bundleOf(
|
||||
RESULT_COUNTRY to country
|
||||
)
|
||||
)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
)
|
||||
@@ -89,14 +103,16 @@ class CountryCodeFragment : ComposeFragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel.loadCountries()
|
||||
val initialCountry = arguments?.getParcelableCompat(REQUEST_COUNTRY, Country::class.java)
|
||||
viewModel.loadCountries(initialCountry)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun Screen(
|
||||
state: CountryCodeState,
|
||||
title: String,
|
||||
onSearch: (String) -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
onClick: (Country) -> Unit = {}
|
||||
@@ -104,7 +120,7 @@ private fun Screen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Scaffolds.DefaultTopAppBar(
|
||||
title = stringResource(R.string.CountryCodeFragment__your_country),
|
||||
title = title,
|
||||
titleContent = { _, title ->
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||
},
|
||||
@@ -113,16 +129,19 @@ private fun Screen(
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
item {
|
||||
stickyHeader {
|
||||
SearchBar(
|
||||
text = state.query,
|
||||
onSearch = onSearch
|
||||
)
|
||||
Spacer(modifier = Modifier.size(18.dp))
|
||||
}
|
||||
|
||||
if (state.countryList.isEmpty()) {
|
||||
@@ -149,6 +168,12 @@ private fun Screen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.startingIndex) {
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(index = state.startingIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +185,7 @@ fun CountryItem(
|
||||
) {
|
||||
val emoji = country.emoji
|
||||
val name = country.name
|
||||
val code = country.countryCode
|
||||
val code = "+${country.countryCode}"
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -253,21 +278,30 @@ fun SearchBar(
|
||||
onValueChange = { onSearch(it) },
|
||||
placeholder = { Text(hint) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
showKeyboard = !showKeyboard
|
||||
focusRequester.requestFocus()
|
||||
}) {
|
||||
if (showKeyboard) {
|
||||
if (text.isNotEmpty()) {
|
||||
IconButton(onClick = { onSearch("") }) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_keyboard_24),
|
||||
contentDescription = null
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24),
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_x_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = {
|
||||
showKeyboard = !showKeyboard
|
||||
focusRequester.requestFocus()
|
||||
}) {
|
||||
if (showKeyboard) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_keyboard_24),
|
||||
contentDescription = null
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
@@ -279,10 +313,11 @@ fun SearchBar(
|
||||
),
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(bottom = 18.dp, start = 16.dp, end = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(54.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.padding(horizontal = 16.dp),
|
||||
.focusRequester(focusRequester),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = TextFieldDefaults.colors(
|
||||
// TODO move to SignalTheme
|
||||
@@ -302,15 +337,16 @@ private fun ScreenPreview() {
|
||||
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")
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 2, "CA"),
|
||||
Country("\uD83C\uDDF2\uD83C\uDDFD", "Mexico", 3, "MX")
|
||||
),
|
||||
commonCountryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+4"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+5")
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 4, "US"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 5, "CA")
|
||||
)
|
||||
)
|
||||
),
|
||||
title = "Your country"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -322,7 +358,8 @@ private fun LoadingScreenPreview() {
|
||||
Screen(
|
||||
state = CountryCodeState(
|
||||
countryList = emptyList()
|
||||
)
|
||||
),
|
||||
title = "Your country"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,5 +12,6 @@ data class CountryCodeState(
|
||||
val query: String = "",
|
||||
val countryList: List<Country> = emptyList(),
|
||||
val commonCountryList: List<Country> = emptyList(),
|
||||
val filteredList: List<Country> = emptyList()
|
||||
val filteredList: List<Country> = emptyList(),
|
||||
val startingIndex: Int = 0
|
||||
)
|
||||
|
||||
@@ -7,15 +7,12 @@ 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
|
||||
@@ -42,54 +39,30 @@ class CountryCodeViewModel : ViewModel() {
|
||||
it.copy(
|
||||
query = filterBy,
|
||||
filteredList = state.value.countryList.filter { country: Country ->
|
||||
country.name.contains(filterBy, ignoreCase = true) || country.countryCode.contains(filterBy)
|
||||
country.name.contains(filterBy, ignoreCase = true) ||
|
||||
country.countryCode.toString().contains(filterBy) ||
|
||||
(filterBy.equals("usa", ignoreCase = true) && country.name.equals("United States", ignoreCase = true))
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun loadCountries() {
|
||||
loadCommonCountryList()
|
||||
fun loadCountries(initialCountry: Country? = null) {
|
||||
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()))
|
||||
val countryList = CountryUtils.getCountries()
|
||||
val commonCountryList = CountryUtils.getCommonCountries()
|
||||
val startingIndex = if (initialCountry == null || commonCountryList.contains(initialCountry)) {
|
||||
0
|
||||
} else {
|
||||
countryList.indexOf(initialCountry) + commonCountryList.size
|
||||
}
|
||||
|
||||
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
|
||||
countryList = countryList,
|
||||
commonCountryList = commonCountryList,
|
||||
startingIndex = startingIndex
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.countrycode
|
||||
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter
|
||||
import java.text.Collator
|
||||
import java.util.Locale
|
||||
|
||||
/**
|
||||
@@ -12,6 +15,38 @@ import java.util.Locale
|
||||
*/
|
||||
object CountryUtils {
|
||||
|
||||
/** A hardcoded list of countries to suggest during registration. Can change at any time. */
|
||||
private val COMMON_COUNTRIES = listOf("US", "DE", "IN", "NL", "UA")
|
||||
|
||||
fun getCountries(): List<Country> {
|
||||
val collator = Collator.getInstance(Locale.getDefault())
|
||||
collator.strength = Collator.PRIMARY
|
||||
|
||||
return PhoneNumberUtil.getInstance().supportedRegions
|
||||
.map { region ->
|
||||
Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""),
|
||||
emoji = countryToEmoji(region),
|
||||
countryCode = PhoneNumberUtil.getInstance().getCountryCodeForRegion(region),
|
||||
regionCode = region
|
||||
)
|
||||
}.sortedWith { lhs, rhs ->
|
||||
collator.compare(lhs.name.lowercase(Locale.getDefault()), rhs.name.lowercase(Locale.getDefault()))
|
||||
}
|
||||
}
|
||||
|
||||
fun getCommonCountries(): List<Country> {
|
||||
return COMMON_COUNTRIES
|
||||
.map { region ->
|
||||
Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(region).orElse(""),
|
||||
emoji = countryToEmoji(region),
|
||||
countryCode = PhoneNumberUtil.getInstance().getCountryCodeForRegion(region),
|
||||
regionCode = region
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun countryToEmoji(countryCode: String): String {
|
||||
return if (countryCode.isNotEmpty()) {
|
||||
countryCode
|
||||
@@ -23,7 +58,4 @@ object CountryUtils {
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/** A hardcoded list of countries to suggest during registration. Can change at any time. */
|
||||
val COMMON_COUNTRIES = listOf("US", "DE", "IN", "NL", "UA")
|
||||
}
|
||||
|
||||
@@ -37,6 +37,7 @@ import com.google.i18n.phonenumbers.NumberParseException
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
@@ -58,6 +59,7 @@ 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.countrycode.CountryCodeFragment
|
||||
import org.thoughtcrime.securesms.registration.ui.toE164
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
import org.thoughtcrime.securesms.util.CommunicationActions
|
||||
@@ -109,6 +111,14 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
moveToCountryPickerScreen()
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(
|
||||
CountryCodeFragment.REQUEST_KEY_COUNTRY,
|
||||
this
|
||||
) { _, bundle ->
|
||||
val country: Country = bundle.getParcelableCompat(CountryCodeFragment.RESULT_COUNTRY, Country::class.java)!!
|
||||
fragmentViewModel.setCountry(country.countryCode, country)
|
||||
}
|
||||
|
||||
spinnerAdapter = ArrayAdapter<CountryPrefix>(
|
||||
requireContext(),
|
||||
R.layout.registration_country_code_dropdown_item,
|
||||
@@ -162,9 +172,11 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
.map { it.phoneNumberRegionCode }
|
||||
.distinctUntilChanged()
|
||||
.observe(viewLifecycleOwner) { regionCode ->
|
||||
currentPhoneNumberFormatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode)
|
||||
reformatText(phoneNumberInputLayout.text)
|
||||
phoneNumberInputLayout.requestFocus()
|
||||
if (regionCode.isNotNullOrBlank()) {
|
||||
currentPhoneNumberFormatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode)
|
||||
reformatText(phoneNumberInputLayout.text)
|
||||
phoneNumberInputLayout.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
fragmentViewModel.uiState.observe(viewLifecycleOwner) { fragmentState ->
|
||||
@@ -184,18 +196,12 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
initializeInputFields()
|
||||
|
||||
val existingPhoneNumber = sharedViewModel.phoneNumber
|
||||
val country = sharedViewModel.country
|
||||
|
||||
if (country != null) {
|
||||
binding.countryEmoji.text = country.emoji
|
||||
binding.country.text = country.name
|
||||
spinnerView.setText(country.countryCode)
|
||||
} else if (existingPhoneNumber != null) {
|
||||
if (existingPhoneNumber != null) {
|
||||
fragmentViewModel.restoreState(existingPhoneNumber)
|
||||
spinnerView.setText(existingPhoneNumber.countryCode.toString())
|
||||
phoneNumberInputLayout.setText(existingPhoneNumber.nationalNumber.toString())
|
||||
} else {
|
||||
spinnerView.setText(fragmentViewModel.countryPrefix().toString())
|
||||
} else if (spinnerView.text?.isEmpty() == true) {
|
||||
spinnerView.setText(fragmentViewModel.getDefaultCountryCode(requireContext()).toString())
|
||||
}
|
||||
|
||||
ViewUtil.focusAndShowKeyboard(phoneNumberInputLayout)
|
||||
@@ -203,8 +209,15 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
|
||||
private fun updateCountrySelection(country: Country?) {
|
||||
if (country != null) {
|
||||
binding.countryEmoji.visible = true
|
||||
binding.countryEmoji.text = country.emoji
|
||||
binding.country.text = country.name
|
||||
if (spinnerView.text.toString() != country.countryCode.toString()) {
|
||||
spinnerView.setText(country.countryCode.toString())
|
||||
}
|
||||
} else {
|
||||
binding.countryEmoji.visible = false
|
||||
binding.country.text = getString(R.string.RegistrationActivity_select_a_country)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -242,21 +255,13 @@ 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()
|
||||
if (sharedViewModel.country != null) {
|
||||
fragmentViewModel.setCountry(countryCode, sharedViewModel.country)
|
||||
sharedViewModel.clearCountry()
|
||||
} else {
|
||||
fragmentViewModel.setCountry(countryCode)
|
||||
}
|
||||
fragmentViewModel.setCountry(countryCode)
|
||||
} else {
|
||||
binding.countryCode.editText?.setHint(R.string.RegistrationActivity_default_country_code)
|
||||
fragmentViewModel.clearCountry()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -662,7 +667,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
}
|
||||
|
||||
private fun moveToCountryPickerScreen() {
|
||||
findNavController().safeNavigate(R.id.action_countryPicker)
|
||||
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionCountryPicker(fragmentViewModel.country))
|
||||
}
|
||||
|
||||
private fun popBackStack() {
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.registration.ui.phonenumber
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import com.google.i18n.phonenumbers.NumberParseException
|
||||
@@ -17,6 +18,7 @@ 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.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter
|
||||
|
||||
/**
|
||||
@@ -54,25 +56,73 @@ class EnterPhoneNumberViewModel : ViewModel() {
|
||||
it.copy(mode = value)
|
||||
}
|
||||
|
||||
fun countryPrefix(): CountryPrefix {
|
||||
return supportedCountryPrefixes[store.value.countryPrefixIndex]
|
||||
fun getDefaultCountryCode(context: Context): Int {
|
||||
val existingCountry = store.value.country
|
||||
val maybeRegionCode = Util.getNetworkCountryIso(context)
|
||||
val regionCode = if (maybeRegionCode != null && supportedCountryPrefixes.any { it.regionCode == maybeRegionCode }) {
|
||||
maybeRegionCode
|
||||
} else {
|
||||
Log.w(TAG, "Could not find region code")
|
||||
"US"
|
||||
}
|
||||
|
||||
val countryCode = PhoneNumberUtil.getInstance().getCountryCodeForRegion(regionCode)
|
||||
|
||||
store.update {
|
||||
it.copy(
|
||||
country = existingCountry ?: Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(regionCode).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(regionCode),
|
||||
countryCode = countryCode,
|
||||
regionCode = regionCode
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return existingCountry?.countryCode ?: countryCode
|
||||
}
|
||||
|
||||
val country: Country?
|
||||
get() = store.value.country
|
||||
|
||||
fun setPhoneNumber(phoneNumber: String?) {
|
||||
store.update { it.copy(phoneNumber = phoneNumber ?: "") }
|
||||
}
|
||||
|
||||
fun clearCountry() {
|
||||
store.update {
|
||||
it.copy(
|
||||
country = null,
|
||||
phoneNumberRegionCode = "",
|
||||
countryPrefixIndex = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCountry(digits: Int, country: Country? = null) {
|
||||
val matchingIndex = countryCodeToAdapterIndex(digits)
|
||||
if (matchingIndex == -1) {
|
||||
Log.d(TAG, "Invalid country code specified $digits")
|
||||
if (country == null && digits == store.value.country?.countryCode) {
|
||||
return
|
||||
}
|
||||
|
||||
val matchingIndex = countryCodeToAdapterIndex(digits)
|
||||
if (matchingIndex == -1) {
|
||||
Log.d(TAG, "Invalid country code specified $digits")
|
||||
store.update {
|
||||
it.copy(
|
||||
country = null,
|
||||
phoneNumberRegionCode = "",
|
||||
countryPrefixIndex = 0
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val regionCode = supportedCountryPrefixes[matchingIndex].regionCode
|
||||
val matchedCountry = Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(supportedCountryPrefixes[matchingIndex].regionCode).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(supportedCountryPrefixes[matchingIndex].regionCode),
|
||||
countryCode = digits.toString()
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(regionCode).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(regionCode),
|
||||
countryCode = digits,
|
||||
regionCode = regionCode
|
||||
)
|
||||
|
||||
store.update {
|
||||
@@ -90,7 +140,8 @@ class EnterPhoneNumberViewModel : ViewModel() {
|
||||
|
||||
fun isEnteredNumberPossible(state: EnterPhoneNumberState): Boolean {
|
||||
return try {
|
||||
PhoneNumberUtil.getInstance().isPossibleNumber(parsePhoneNumber(state))
|
||||
state.country != null &&
|
||||
PhoneNumberUtil.getInstance().isPossibleNumber(parsePhoneNumber(state))
|
||||
} catch (ex: NumberParseException) {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ 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
|
||||
@@ -54,8 +53,7 @@ data class RegistrationState(
|
||||
val networkError: Throwable? = null,
|
||||
val sessionCreationError: RegistrationSessionResult? = null,
|
||||
val sessionStateError: VerificationCodeRequestResult? = null,
|
||||
val registerAccountError: RegisterAccountResult? = null,
|
||||
val country: Country? = null
|
||||
val registerAccountError: RegisterAccountResult? = null
|
||||
) {
|
||||
val challengesRemaining: List<Challenge> = challengesRequested.filterNot { it in challengesPresented }
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ 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
|
||||
@@ -129,9 +128,6 @@ class RegistrationViewModel : ViewModel() {
|
||||
val phoneNumber: Phonenumber.PhoneNumber?
|
||||
get() = store.value.phoneNumber
|
||||
|
||||
val country: Country?
|
||||
get() = store.value.country
|
||||
|
||||
fun maybePrefillE164(context: Context) {
|
||||
Log.v(TAG, "maybePrefillE164()")
|
||||
if (Permissions.hasAll(context, Manifest.permission.READ_PHONE_STATE, Manifest.permission.READ_PHONE_NUMBERS)) {
|
||||
@@ -1046,18 +1042,6 @@ 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)
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
/*
|
||||
* 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)
|
||||
@@ -4,9 +4,10 @@ package org.thoughtcrime.securesms.registrationv3.ui.countrycode
|
||||
|
||||
import android.os.Bundle
|
||||
import android.view.View
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
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
|
||||
@@ -14,6 +15,7 @@ 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.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.CircularProgressIndicator
|
||||
@@ -25,9 +27,11 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextField
|
||||
import androidx.compose.material3.TextFieldDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -45,19 +49,24 @@ import androidx.compose.ui.text.input.KeyboardType
|
||||
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.core.os.bundleOf
|
||||
import androidx.fragment.app.setFragmentResult
|
||||
import androidx.fragment.app.viewModels
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import androidx.navigation.fragment.findNavController
|
||||
import kotlinx.coroutines.launch
|
||||
import org.signal.core.ui.Dividers
|
||||
import org.signal.core.ui.IconButtons.IconButton
|
||||
import org.signal.core.ui.Previews
|
||||
import org.signal.core.ui.Scaffolds
|
||||
import org.signal.core.ui.SignalPreview
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
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
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryCodeState
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryCodeViewModel
|
||||
|
||||
/**
|
||||
* Country picker fragment used in registration V3
|
||||
@@ -66,10 +75,12 @@ class CountryCodeFragment : ComposeFragment() {
|
||||
|
||||
companion object {
|
||||
private val TAG = Log.tag(CountryCodeFragment::class.java)
|
||||
const val REQUEST_KEY_COUNTRY = "request_key_country"
|
||||
const val REQUEST_COUNTRY = "country"
|
||||
const val RESULT_COUNTRY = "country"
|
||||
}
|
||||
|
||||
private val viewModel: CountryCodeViewModel by viewModels()
|
||||
private val sharedViewModel by activityViewModels<RegistrationViewModel>()
|
||||
|
||||
@Composable
|
||||
override fun FragmentContent() {
|
||||
@@ -77,10 +88,16 @@ class CountryCodeFragment : ComposeFragment() {
|
||||
|
||||
Screen(
|
||||
state = state,
|
||||
title = stringResource(R.string.CountryCodeFragment__your_country),
|
||||
onSearch = { search -> viewModel.filterCountries(search) },
|
||||
onDismissed = { findNavController().popBackStack() },
|
||||
onClick = { country ->
|
||||
sharedViewModel.setCurrentCountryPicked(country)
|
||||
setFragmentResult(
|
||||
REQUEST_KEY_COUNTRY,
|
||||
bundleOf(
|
||||
RESULT_COUNTRY to country
|
||||
)
|
||||
)
|
||||
findNavController().popBackStack()
|
||||
}
|
||||
)
|
||||
@@ -89,14 +106,16 @@ class CountryCodeFragment : ComposeFragment() {
|
||||
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
|
||||
super.onViewCreated(view, savedInstanceState)
|
||||
|
||||
viewModel.loadCountries()
|
||||
val initialCountry = arguments?.getParcelableCompat(REQUEST_COUNTRY, Country::class.java)
|
||||
viewModel.loadCountries(initialCountry)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
private fun Screen(
|
||||
fun Screen(
|
||||
state: CountryCodeState,
|
||||
title: String,
|
||||
onSearch: (String) -> Unit = {},
|
||||
onDismissed: () -> Unit = {},
|
||||
onClick: (Country) -> Unit = {}
|
||||
@@ -104,7 +123,7 @@ private fun Screen(
|
||||
Scaffold(
|
||||
topBar = {
|
||||
Scaffolds.DefaultTopAppBar(
|
||||
title = stringResource(R.string.CountryCodeFragment__your_country),
|
||||
title = title,
|
||||
titleContent = { _, title ->
|
||||
Text(text = title, style = MaterialTheme.typography.titleLarge)
|
||||
},
|
||||
@@ -113,16 +132,19 @@ private fun Screen(
|
||||
)
|
||||
}
|
||||
) { padding ->
|
||||
val listState = rememberLazyListState()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
LazyColumn(
|
||||
state = listState,
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(padding)
|
||||
) {
|
||||
item {
|
||||
stickyHeader {
|
||||
SearchBar(
|
||||
text = state.query,
|
||||
onSearch = onSearch
|
||||
)
|
||||
Spacer(modifier = Modifier.size(18.dp))
|
||||
}
|
||||
|
||||
if (state.countryList.isEmpty()) {
|
||||
@@ -132,12 +154,14 @@ private fun Screen(
|
||||
)
|
||||
}
|
||||
} else if (state.query.isEmpty()) {
|
||||
items(state.commonCountryList) { country ->
|
||||
CountryItem(country, onClick)
|
||||
}
|
||||
if (state.commonCountryList.isNotEmpty()) {
|
||||
items(state.commonCountryList) { country ->
|
||||
CountryItem(country, onClick)
|
||||
}
|
||||
|
||||
item {
|
||||
Dividers.Default()
|
||||
item {
|
||||
Dividers.Default()
|
||||
}
|
||||
}
|
||||
|
||||
items(state.countryList) { country ->
|
||||
@@ -149,6 +173,12 @@ private fun Screen(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
LaunchedEffect(state.startingIndex) {
|
||||
coroutineScope.launch {
|
||||
listState.scrollToItem(index = state.startingIndex)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,7 +190,7 @@ fun CountryItem(
|
||||
) {
|
||||
val emoji = country.emoji
|
||||
val name = country.name
|
||||
val code = country.countryCode
|
||||
val code = "+${country.countryCode}"
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier
|
||||
@@ -253,21 +283,30 @@ fun SearchBar(
|
||||
onValueChange = { onSearch(it) },
|
||||
placeholder = { Text(hint) },
|
||||
trailingIcon = {
|
||||
IconButton(onClick = {
|
||||
showKeyboard = !showKeyboard
|
||||
focusRequester.requestFocus()
|
||||
}) {
|
||||
if (showKeyboard) {
|
||||
if (text.isNotEmpty()) {
|
||||
IconButton(onClick = { onSearch("") }) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_keyboard_24),
|
||||
contentDescription = null
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24),
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_x_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
} else {
|
||||
IconButton(onClick = {
|
||||
showKeyboard = !showKeyboard
|
||||
focusRequester.requestFocus()
|
||||
}) {
|
||||
if (showKeyboard) {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_keyboard_24),
|
||||
contentDescription = null
|
||||
)
|
||||
} else {
|
||||
Icon(
|
||||
imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24),
|
||||
contentDescription = null
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
keyboardOptions = KeyboardOptions(
|
||||
@@ -279,10 +318,11 @@ fun SearchBar(
|
||||
),
|
||||
shape = RoundedCornerShape(32.dp),
|
||||
modifier = modifier
|
||||
.background(MaterialTheme.colorScheme.background)
|
||||
.padding(bottom = 18.dp, start = 16.dp, end = 16.dp)
|
||||
.fillMaxWidth()
|
||||
.height(54.dp)
|
||||
.focusRequester(focusRequester)
|
||||
.padding(horizontal = 16.dp),
|
||||
.focusRequester(focusRequester),
|
||||
visualTransformation = VisualTransformation.None,
|
||||
colors = TextFieldDefaults.colors(
|
||||
// TODO move to SignalTheme
|
||||
@@ -302,15 +342,16 @@ private fun ScreenPreview() {
|
||||
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")
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 2, "CA"),
|
||||
Country("\uD83C\uDDF2\uD83C\uDDFD", "Mexico", 3, "MX")
|
||||
),
|
||||
commonCountryList = mutableListOf(
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", "+4"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", "+5")
|
||||
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 4, "US"),
|
||||
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 5, "CA")
|
||||
)
|
||||
)
|
||||
),
|
||||
title = "Your country"
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -322,7 +363,8 @@ private fun LoadingScreenPreview() {
|
||||
Screen(
|
||||
state = CountryCodeState(
|
||||
countryList = emptyList()
|
||||
)
|
||||
),
|
||||
title = "Your country"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
/*
|
||||
* 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()
|
||||
)
|
||||
@@ -1,98 +0,0 @@
|
||||
/*
|
||||
* 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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import com.google.i18n.phonenumbers.NumberParseException
|
||||
import com.google.i18n.phonenumbers.PhoneNumberUtil
|
||||
import com.google.i18n.phonenumbers.Phonenumber.PhoneNumber
|
||||
import org.signal.core.util.ThreadUtil
|
||||
import org.signal.core.util.getParcelableCompat
|
||||
import org.signal.core.util.isNotNullOrBlank
|
||||
import org.signal.core.util.logging.Log
|
||||
import org.thoughtcrime.securesms.LoggingFragment
|
||||
@@ -54,13 +55,14 @@ import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionC
|
||||
import org.thoughtcrime.securesms.registration.data.network.RegistrationSessionResult
|
||||
import org.thoughtcrime.securesms.registration.data.network.VerificationCodeRequestResult
|
||||
import org.thoughtcrime.securesms.registration.fragments.RegistrationViewDelegate.setDebugLogSubmitMultiTapView
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.CountryCodeFragment
|
||||
import org.thoughtcrime.securesms.registration.ui.toE164
|
||||
import org.thoughtcrime.securesms.registration.util.CountryPrefix
|
||||
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
|
||||
@@ -114,6 +116,14 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
moveToCountryPickerScreen()
|
||||
}
|
||||
|
||||
parentFragmentManager.setFragmentResultListener(
|
||||
CountryCodeFragment.REQUEST_KEY_COUNTRY,
|
||||
this
|
||||
) { _, bundle ->
|
||||
val country: Country = bundle.getParcelableCompat(CountryCodeFragment.RESULT_COUNTRY, Country::class.java)!!
|
||||
fragmentViewModel.setCountry(country.countryCode, country)
|
||||
}
|
||||
|
||||
spinnerAdapter = ArrayAdapter<CountryPrefix>(
|
||||
requireContext(),
|
||||
R.layout.registration_country_code_dropdown_item,
|
||||
@@ -167,9 +177,11 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
.map { it.phoneNumberRegionCode }
|
||||
.distinctUntilChanged()
|
||||
.observe(viewLifecycleOwner) { regionCode ->
|
||||
currentPhoneNumberFormatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode)
|
||||
reformatText(phoneNumberInputLayout.text)
|
||||
phoneNumberInputLayout.requestFocus()
|
||||
if (regionCode.isNotNullOrBlank()) {
|
||||
currentPhoneNumberFormatter = PhoneNumberUtil.getInstance().getAsYouTypeFormatter(regionCode)
|
||||
reformatText(phoneNumberInputLayout.text)
|
||||
phoneNumberInputLayout.requestFocus()
|
||||
}
|
||||
}
|
||||
|
||||
fragmentViewModel.uiState.observe(viewLifecycleOwner) { fragmentState ->
|
||||
@@ -189,18 +201,12 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
initializeInputFields()
|
||||
|
||||
val existingPhoneNumber = sharedViewModel.phoneNumber
|
||||
|
||||
val country = sharedViewModel.country
|
||||
if (country != null) {
|
||||
binding.countryEmoji.text = country.emoji
|
||||
binding.country.text = country.name
|
||||
spinnerView.setText(country.countryCode)
|
||||
} else if (existingPhoneNumber != null) {
|
||||
if (existingPhoneNumber != null) {
|
||||
fragmentViewModel.restoreState(existingPhoneNumber)
|
||||
spinnerView.setText(existingPhoneNumber.countryCode.toString())
|
||||
phoneNumberInputLayout.setText(existingPhoneNumber.nationalNumber.toString())
|
||||
} else {
|
||||
spinnerView.setText(fragmentViewModel.countryPrefix().toString())
|
||||
} else if (spinnerView.text?.isEmpty() == true) {
|
||||
spinnerView.setText(fragmentViewModel.getDefaultCountryCode(requireContext()).toString())
|
||||
}
|
||||
|
||||
if (enterPhoneNumberMode == EnterPhoneNumberMode.RESTART_AFTER_COLLECTION && (savedInstanceState == null && !processedResumeMode)) {
|
||||
@@ -213,8 +219,15 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
|
||||
private fun updateCountrySelection(country: Country?) {
|
||||
if (country != null) {
|
||||
binding.countryEmoji.visible = true
|
||||
binding.countryEmoji.text = country.emoji
|
||||
binding.country.text = country.name
|
||||
if (spinnerView.text.toString() != country.countryCode.toString()) {
|
||||
spinnerView.setText(country.countryCode.toString())
|
||||
}
|
||||
} else {
|
||||
binding.countryEmoji.visible = false
|
||||
binding.country.text = getString(R.string.RegistrationActivity_select_a_country)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -252,21 +265,13 @@ 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()
|
||||
if (sharedViewModel.country != null) {
|
||||
fragmentViewModel.setCountry(countryCode, sharedViewModel.country)
|
||||
sharedViewModel.clearCountry()
|
||||
} else {
|
||||
fragmentViewModel.setCountry(countryCode)
|
||||
}
|
||||
fragmentViewModel.setCountry(countryCode)
|
||||
} else {
|
||||
binding.countryCode.editText?.setHint(R.string.RegistrationActivity_default_country_code)
|
||||
fragmentViewModel.clearCountry()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -681,7 +686,7 @@ class EnterPhoneNumberFragment : LoggingFragment(R.layout.fragment_registration_
|
||||
}
|
||||
|
||||
private fun moveToCountryPickerScreen() {
|
||||
findNavController().safeNavigate(R.id.action_countryPicker)
|
||||
findNavController().safeNavigate(EnterPhoneNumberFragmentDirections.actionCountryPicker(fragmentViewModel.country))
|
||||
}
|
||||
|
||||
private fun popBackStack() {
|
||||
|
||||
@@ -5,8 +5,8 @@
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
|
||||
|
||||
import org.thoughtcrime.securesms.registration.ui.countrycode.Country
|
||||
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.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
package org.thoughtcrime.securesms.registrationv3.ui.phonenumber
|
||||
|
||||
import android.content.Context
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.asLiveData
|
||||
import com.google.i18n.phonenumbers.NumberParseException
|
||||
@@ -13,10 +14,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.Country
|
||||
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.thoughtcrime.securesms.util.Util
|
||||
import org.whispersystems.signalservice.api.util.PhoneNumberFormatter
|
||||
|
||||
/**
|
||||
@@ -54,23 +56,73 @@ class EnterPhoneNumberViewModel : ViewModel() {
|
||||
it.copy(mode = value)
|
||||
}
|
||||
|
||||
fun countryPrefix(): CountryPrefix = supportedCountryPrefixes[store.value.countryPrefixIndex]
|
||||
fun getDefaultCountryCode(context: Context): Int {
|
||||
val existingCountry = store.value.country
|
||||
val maybeRegionCode = Util.getNetworkCountryIso(context)
|
||||
val regionCode = if (maybeRegionCode != null && supportedCountryPrefixes.any { it.regionCode == maybeRegionCode }) {
|
||||
maybeRegionCode
|
||||
} else {
|
||||
Log.w(TAG, "Could not find region code")
|
||||
"US"
|
||||
}
|
||||
|
||||
val countryCode = PhoneNumberUtil.getInstance().getCountryCodeForRegion(regionCode)
|
||||
|
||||
store.update {
|
||||
it.copy(
|
||||
country = existingCountry ?: Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(regionCode).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(regionCode),
|
||||
countryCode = countryCode,
|
||||
regionCode = regionCode
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
return existingCountry?.countryCode ?: countryCode
|
||||
}
|
||||
|
||||
val country: Country?
|
||||
get() = store.value.country
|
||||
|
||||
fun setPhoneNumber(phoneNumber: String?) {
|
||||
store.update { it.copy(phoneNumber = phoneNumber ?: "") }
|
||||
}
|
||||
|
||||
fun clearCountry() {
|
||||
store.update {
|
||||
it.copy(
|
||||
country = null,
|
||||
phoneNumberRegionCode = "",
|
||||
countryPrefixIndex = 0
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun setCountry(digits: Int, country: Country? = null) {
|
||||
val matchingIndex = countryCodeToAdapterIndex(digits)
|
||||
if (matchingIndex == -1) {
|
||||
Log.d(TAG, "Invalid country code specified $digits")
|
||||
if (country == null && digits == store.value.country?.countryCode) {
|
||||
return
|
||||
}
|
||||
|
||||
val matchingIndex = countryCodeToAdapterIndex(digits)
|
||||
if (matchingIndex == -1) {
|
||||
Log.d(TAG, "Invalid country code specified $digits")
|
||||
store.update {
|
||||
it.copy(
|
||||
country = null,
|
||||
phoneNumberRegionCode = "",
|
||||
countryPrefixIndex = 0
|
||||
)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
val regionCode = supportedCountryPrefixes[matchingIndex].regionCode
|
||||
val matchedCountry = Country(
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(supportedCountryPrefixes[matchingIndex].regionCode).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(supportedCountryPrefixes[matchingIndex].regionCode),
|
||||
countryCode = digits.toString()
|
||||
name = PhoneNumberFormatter.getRegionDisplayName(regionCode).orElse(""),
|
||||
emoji = CountryUtils.countryToEmoji(regionCode),
|
||||
countryCode = digits,
|
||||
regionCode = regionCode
|
||||
)
|
||||
|
||||
store.update {
|
||||
@@ -88,7 +140,8 @@ class EnterPhoneNumberViewModel : ViewModel() {
|
||||
|
||||
fun isEnteredNumberPossible(state: EnterPhoneNumberState): Boolean {
|
||||
return try {
|
||||
PhoneNumberUtil.getInstance().isPossibleNumber(parsePhoneNumber(state))
|
||||
state.country != null &&
|
||||
PhoneNumberUtil.getInstance().isPossibleNumber(parsePhoneNumber(state))
|
||||
} catch (ex: NumberParseException) {
|
||||
false
|
||||
}
|
||||
|
||||
@@ -232,6 +232,11 @@ public class Util {
|
||||
return Optional.ofNullable(simCountryIso != null ? simCountryIso.toUpperCase() : null);
|
||||
}
|
||||
|
||||
public static @Nullable String getNetworkCountryIso(Context context) {
|
||||
String networkCountryIso = ((TelephonyManager)context.getSystemService(Context.TELEPHONY_SERVICE)).getNetworkCountryIso();
|
||||
return networkCountryIso == null ? null : networkCountryIso.toUpperCase();
|
||||
}
|
||||
|
||||
public static @NonNull <T> T firstNonNull(@Nullable T optional, @NonNull T fallback) {
|
||||
return optional != null ? optional : fallback;
|
||||
}
|
||||
|
||||
5
app/src/main/res/drawable/symbol_plus_9.xml
Normal file
5
app/src/main/res/drawable/symbol_plus_9.xml
Normal file
@@ -0,0 +1,5 @@
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="9dp" android:viewportHeight="9" android:viewportWidth="9" android:width="9dp">
|
||||
|
||||
<path android:fillAlpha="0.6" android:fillColor="#44464D" android:pathData="M8.4063,3.8984V5.2578H0.6094V3.8984H8.4063ZM5.2344,0.5781V8.8594H3.7891V0.5781H5.2344Z"/>
|
||||
|
||||
</vector>
|
||||
@@ -43,13 +43,18 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:imeOptions="actionDone"
|
||||
android:inputType="number"
|
||||
android:digits="+1234567890"
|
||||
android:maxLength="4"
|
||||
android:digits="1234567890"
|
||||
android:maxLength="3"
|
||||
android:padding="0dp"
|
||||
android:minHeight="56dp"
|
||||
android:minWidth="76dp"
|
||||
android:maxLines="1"
|
||||
android:singleLine="true"
|
||||
android:textAlignment="center"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant"
|
||||
android:text="@string/RegistrationActivity_default_country_code" />
|
||||
android:textAppearance="@style/Signal.Text.BodyLarge"
|
||||
android:drawableStart="@drawable/symbol_plus_9"
|
||||
android:drawablePadding="4dp"
|
||||
android:gravity="start|center_vertical"
|
||||
android:textColor="@color/signal_colorOnSurfaceVariant" />
|
||||
|
||||
</com.google.android.material.textfield.TextInputLayout>
|
||||
|
||||
@@ -108,8 +113,8 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="36dp"
|
||||
android:layout_marginHorizontal="24dp"
|
||||
android:paddingVertical="16dp"
|
||||
android:background="@drawable/country_picker_background"
|
||||
android:minHeight="56dp"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@id/verify_subheader">
|
||||
|
||||
@@ -61,7 +61,13 @@
|
||||
<fragment
|
||||
android:id="@+id/countryPickerFragment"
|
||||
android:name="org.thoughtcrime.securesms.registration.ui.countrycode.CountryCodeFragment"
|
||||
tools:layout="@layout/fragment_registration_country_picker" />
|
||||
tools:layout="@layout/fragment_registration_country_picker">
|
||||
|
||||
<argument
|
||||
android:name="country"
|
||||
app:argType="org.thoughtcrime.securesms.registration.ui.countrycode.Country"
|
||||
app:nullable="true" />
|
||||
</fragment>
|
||||
|
||||
<fragment
|
||||
android:id="@+id/enterPhoneNumberFragment"
|
||||
|
||||
@@ -118,7 +118,13 @@
|
||||
<fragment
|
||||
android:id="@+id/countryPickerFragment"
|
||||
android:name="org.thoughtcrime.securesms.registrationv3.ui.countrycode.CountryCodeFragment"
|
||||
tools:layout="@layout/fragment_registration_country_picker"/>
|
||||
tools:layout="@layout/fragment_registration_country_picker">
|
||||
|
||||
<argument
|
||||
android:name="country"
|
||||
app:argType="org.thoughtcrime.securesms.registration.ui.countrycode.Country"
|
||||
app:nullable="true" />
|
||||
</fragment>
|
||||
|
||||
|
||||
<fragment
|
||||
|
||||
@@ -2556,7 +2556,7 @@
|
||||
<!-- Hint text to select a country -->
|
||||
<string name="RegistrationActivity_select_a_country">Select a country</string>
|
||||
<!-- Hint text explaining that this is where the country code should go -->
|
||||
<string name="RegistrationActivity_default_country_code">+0</string>
|
||||
<string name="RegistrationActivity_default_country_code">0</string>
|
||||
<string name="RegistrationActivity_you_will_receive_a_call_to_verify_this_number">You\'ll receive a call to verify this number.</string>
|
||||
<string name="RegistrationActivity_edit_number">Edit number</string>
|
||||
<string name="RegistrationActivity_missing_google_play_services">Missing Google Play Services</string>
|
||||
|
||||
Reference in New Issue
Block a user