diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationState.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationState.kt index 1025319f9a..45af3ceb2f 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationState.kt @@ -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 = challengesRequested.filterNot { it in challengesPresented } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt index c7100fcf12..ba657a4428 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/RegistrationViewModel.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/Country.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/Country.kt index 15148e0d21..78a361ef7e 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/Country.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/Country.kt @@ -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 diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeFragment.kt index 56e8d5d5d4..d370bd2499 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeFragment.kt @@ -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() @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" ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeState.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeState.kt index b2a657a1c7..85057d2757 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeState.kt @@ -12,5 +12,6 @@ data class CountryCodeState( val query: String = "", val countryList: List = emptyList(), val commonCountryList: List = emptyList(), - val filteredList: List = emptyList() + val filteredList: List = emptyList(), + val startingIndex: Int = 0 ) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeViewModel.kt index 7d3677bc69..3cbe8b2965 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryCodeViewModel.kt @@ -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() - - 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() - 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 ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryUtils.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryUtils.kt index ab155804f4..3b5fdb52ed 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryUtils.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/countrycode/CountryUtils.kt @@ -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 { + 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 { + 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") } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt index 1e55aaab6b..967d1097a7 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberFragment.kt @@ -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( 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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberViewModel.kt index 64528590c6..c5885950af 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registration/ui/phonenumber/EnterPhoneNumberViewModel.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt index d96a6a2d2a..7853313fef 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationState.kt @@ -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 = challengesRequested.filterNot { it in challengesPresented } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt index 6de5eab31e..1abb229479 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/RegistrationViewModel.kt @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/Country.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/Country.kt deleted file mode 100644 index f1f1e8e210..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/Country.kt +++ /dev/null @@ -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) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeFragment.kt index 0ddde47b4c..40deef8e1b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeFragment.kt @@ -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() @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" ) } } diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeState.kt deleted file mode 100644 index cdbf5be240..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeState.kt +++ /dev/null @@ -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 = emptyList(), - val commonCountryList: List = emptyList(), - val filteredList: List = emptyList() -) diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeViewModel.kt deleted file mode 100644 index 00f657268b..0000000000 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/countrycode/CountryCodeViewModel.kt +++ /dev/null @@ -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() - - 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() - 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 - ) - } - } - } -} diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt index a46edd7790..65e8f79511 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberFragment.kt @@ -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( 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() { diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt index 69ef1845af..496e850a50 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberState.kt @@ -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. diff --git a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt index f19e57e8c0..9031931255 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/registrationv3/ui/phonenumber/EnterPhoneNumberViewModel.kt @@ -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 } diff --git a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java index b345b92dcc..2a09f9a09b 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/util/Util.java +++ b/app/src/main/java/org/thoughtcrime/securesms/util/Util.java @@ -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 firstNonNull(@Nullable T optional, @NonNull T fallback) { return optional != null ? optional : fallback; } diff --git a/app/src/main/res/drawable/symbol_plus_9.xml b/app/src/main/res/drawable/symbol_plus_9.xml new file mode 100644 index 0000000000..6a84d3a243 --- /dev/null +++ b/app/src/main/res/drawable/symbol_plus_9.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/layout/fragment_registration_enter_phone_number.xml b/app/src/main/res/layout/fragment_registration_enter_phone_number.xml index 4dbc480d47..3b2ad747e7 100644 --- a/app/src/main/res/layout/fragment_registration_enter_phone_number.xml +++ b/app/src/main/res/layout/fragment_registration_enter_phone_number.xml @@ -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" /> @@ -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"> diff --git a/app/src/main/res/navigation/registration.xml b/app/src/main/res/navigation/registration.xml index f9fc023a17..8413dd6e67 100644 --- a/app/src/main/res/navigation/registration.xml +++ b/app/src/main/res/navigation/registration.xml @@ -61,7 +61,13 @@ + tools:layout="@layout/fragment_registration_country_picker"> + + + + tools:layout="@layout/fragment_registration_country_picker"> + + + Select a country - +0 + 0 You\'ll receive a call to verify this number. Edit number Missing Google Play Services