Add the country code picker to regV5.

This commit is contained in:
Greyson Parrelli
2026-02-12 23:08:09 -05:00
committed by Alex Hart
parent 7b362460e7
commit c91275c5da
17 changed files with 1046 additions and 181 deletions
@@ -38,6 +38,10 @@ import org.signal.registration.screens.accountlocked.AccountLockedState
import org.signal.registration.screens.captcha.CaptchaScreen
import org.signal.registration.screens.captcha.CaptchaScreenEvents
import org.signal.registration.screens.captcha.CaptchaState
import org.signal.registration.screens.countrycode.Country
import org.signal.registration.screens.countrycode.CountryCodePickerRepository
import org.signal.registration.screens.countrycode.CountryCodePickerScreen
import org.signal.registration.screens.countrycode.CountryCodePickerViewModel
import org.signal.registration.screens.permissions.PermissionsScreen
import org.signal.registration.screens.phonenumber.PhoneNumberEntryScreenEvents
import org.signal.registration.screens.phonenumber.PhoneNumberEntryViewModel
@@ -117,6 +121,7 @@ sealed interface RegistrationRoute : NavKey, Parcelable {
}
private const val CAPTCHA_RESULT = "captcha_token"
private const val COUNTRY_CODE_RESULT = "country_code_result"
/**
* Sets up the navigation graph for the registration flow using Navigation 3.
@@ -268,6 +273,12 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
}
}
ResultEffect<Country?>(registrationViewModel.resultBus, COUNTRY_CODE_RESULT) { country ->
if (country != null) {
viewModel.onEvent(PhoneNumberEntryScreenEvents.CountrySelected(country.countryCode, country.regionCode, country.name, country.emoji))
}
}
PhoneNumberScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
@@ -276,9 +287,20 @@ private fun EntryProviderScope<NavKey>.navigationEntries(
// -- Country Code Picker
entry<RegistrationRoute.CountryCodePicker> {
// We'll also want this to be some sort of launch-for-result flow as well
// TODO [registration] - display country code picker
throw NotImplementedError("Country Code Picker not implemented")
val viewModel: CountryCodePickerViewModel = viewModel(
factory = CountryCodePickerViewModel.Factory(
repository = CountryCodePickerRepository(),
parentEventEmitter = parentEventEmitter,
resultBus = registrationViewModel.resultBus,
resultKey = COUNTRY_CODE_RESULT
)
)
val state by viewModel.state.collectAsStateWithLifecycle()
CountryCodePickerScreen(
state = state,
onEvent = { viewModel.onEvent(it) }
)
}
// -- Captcha Screen
@@ -30,10 +30,6 @@ import java.util.Locale
class RegistrationRepository(val context: Context, val networkController: NetworkController, val storageController: StorageController) {
companion object {
private val TAG = Log.tag(RegistrationRepository::class)
}
suspend fun createSession(e164: String): RegistrationNetworkResult<SessionMetadata, CreateSessionError> = withContext(Dispatchers.IO) {
val fcmToken = networkController.getFcmToken()
networkController.createSession(
@@ -308,4 +304,8 @@ class RegistrationRepository(val context: Context, val networkController: Networ
suspend fun getPreExistingRegistrationData(): PreExistingRegistrationData? {
return storageController.getPreExistingRegistrationData()
}
companion object {
private val TAG = Log.tag(RegistrationRepository::class)
}
}
@@ -0,0 +1,16 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.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")
*/
@Parcelize
data class Country(val emoji: String, val name: String, val countryCode: Int, val regionCode: String) : Parcelable
@@ -0,0 +1,64 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.countrycode
import com.google.i18n.phonenumbers.PhoneNumberUtil
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.signal.core.util.E164Util
import java.text.Collator
import java.util.Locale
/**
* Repository for fetching country data used by the country code picker.
*/
class CountryCodePickerRepository {
companion object {
/** A hardcoded list of countries to suggest during registration. Can change at any time. */
private val COMMON_COUNTRIES = listOf("US", "DE", "IN", "NL", "UA")
}
suspend fun getCountries(): List<Country> = withContext(Dispatchers.IO) {
val collator = Collator.getInstance(Locale.getDefault())
collator.strength = Collator.PRIMARY
PhoneNumberUtil.getInstance().supportedRegions
.map { region ->
Country(
name = E164Util.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()))
}
}
suspend fun getCommonCountries(): List<Country> = withContext(Dispatchers.IO) {
COMMON_COUNTRIES.map { region ->
Country(
name = E164Util.getRegionDisplayName(region).orElse(""),
emoji = countryToEmoji(region),
countryCode = PhoneNumberUtil.getInstance().getCountryCodeForRegion(region),
regionCode = region
)
}
}
private fun countryToEmoji(countryCode: String): String {
return if (countryCode.isNotEmpty()) {
countryCode
.uppercase(Locale.US)
.map { char -> Character.codePointAt("$char", 0) - 0x41 + 0x1F1E6 }
.map { codePoint -> Character.toChars(codePoint) }
.joinToString(separator = "") { charArray -> String(charArray) }
} else {
""
}
}
}
@@ -0,0 +1,340 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.countrycode
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.defaultMinSize
import androidx.compose.foundation.layout.fillMaxWidth
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
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.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
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.res.vectorResource
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.buildAnnotatedString
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.withStyle
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Dividers
import org.signal.core.ui.compose.IconButtons.IconButton
import org.signal.core.ui.compose.LargeFontPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.Scaffolds
import org.signal.core.ui.compose.SignalIcons
import org.signal.registration.R
/**
* Screen that allows someone to search and select a country code from a supported list of countries.
*/
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
fun CountryCodePickerScreen(
state: CountryCodeState,
onEvent: (CountryCodePickerScreenEvents) -> Unit
) {
Scaffold(
topBar = {
Scaffolds.DefaultTopAppBar(
title = stringResource(R.string.CountryCodeSelectScreen__your_country),
titleContent = { _, title ->
Text(text = title, style = MaterialTheme.typography.titleLarge)
},
onNavigationClick = { onEvent(CountryCodePickerScreenEvents.Dismissed) },
navigationIcon = SignalIcons.X.imageVector,
navigationContentDescription = stringResource(R.string.CountryCodeSelectScreen__close)
)
}
) { padding ->
val listState = rememberLazyListState()
val coroutineScope = rememberCoroutineScope()
LazyColumn(
state = listState,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(padding)
) {
stickyHeader {
SearchBar(
text = state.query,
onSearch = { onEvent(CountryCodePickerScreenEvents.Search(it)) }
)
}
if (state.countryList.isEmpty()) {
item {
CircularProgressIndicator(
modifier = Modifier.size(56.dp)
)
}
} else if (state.query.isEmpty()) {
if (state.commonCountryList.isNotEmpty()) {
items(state.commonCountryList) { country ->
CountryItem(country, onEvent)
}
item {
Dividers.Default()
}
}
items(state.countryList) { country ->
CountryItem(country, onEvent)
}
} else {
items(state.filteredList) { country ->
CountryItem(country, onEvent, state.query)
}
}
}
LaunchedEffect(state.startingIndex) {
coroutineScope.launch {
listState.scrollToItem(index = state.startingIndex)
}
}
}
}
@Composable
private fun CountryItem(
country: Country,
onEvent: (CountryCodePickerScreenEvents) -> Unit = {},
query: String = ""
) {
val emoji = country.emoji
val name = country.name
val code = "+${country.countryCode}"
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(horizontal = 24.dp)
.fillMaxWidth()
.defaultMinSize(minHeight = 56.dp)
.clickable { onEvent(CountryCodePickerScreenEvents.CountrySelected(country)) }
) {
Text(
text = emoji,
modifier = Modifier.size(24.dp)
)
if (query.isEmpty()) {
Text(
text = name.ifEmpty { stringResource(R.string.CountryCodeSelectScreen__unknown_country) },
modifier = Modifier
.padding(start = 24.dp)
.weight(1f),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = code,
modifier = Modifier.padding(start = 24.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
val annotatedName = buildAnnotatedString {
val startIndex = name.indexOf(query, ignoreCase = true)
if (startIndex >= 0) {
append(name.substring(0, startIndex))
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(name.substring(startIndex, startIndex + query.length))
}
append(name.substring(startIndex + query.length))
} else {
append(name)
}
}
val annotatedCode = buildAnnotatedString {
val startIndex = code.indexOf(query, ignoreCase = true)
if (startIndex >= 0) {
append(code.substring(0, startIndex))
withStyle(style = SpanStyle(fontWeight = FontWeight.Bold)) {
append(code.substring(startIndex, startIndex + query.length))
}
append(code.substring(startIndex + query.length))
} else {
append(code)
}
}
Text(
text = annotatedName,
modifier = Modifier
.padding(start = 24.dp)
.weight(1f),
style = MaterialTheme.typography.bodyLarge
)
Text(
text = annotatedCode,
modifier = Modifier.padding(start = 24.dp),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant
)
}
}
}
@Composable
private fun SearchBar(
text: String,
modifier: Modifier = Modifier,
hint: String = stringResource(R.string.CountryCodeSelectScreen__search_by),
onSearch: (String) -> Unit = {}
) {
val focusRequester = remember { FocusRequester() }
var showKeyboard by remember { mutableStateOf(false) }
TextField(
value = text,
onValueChange = { onSearch(it) },
placeholder = { Text(hint) },
trailingIcon = {
if (text.isNotEmpty()) {
IconButton(onClick = { onSearch("") }) {
Icon(
imageVector = SignalIcons.X.imageVector,
contentDescription = null
)
}
} else {
IconButton(onClick = {
showKeyboard = !showKeyboard
focusRequester.requestFocus()
}) {
if (showKeyboard) {
Icon(
imageVector = SignalIcons.Keyboard.imageVector,
contentDescription = null
)
} else {
Icon(
imageVector = ImageVector.vectorResource(R.drawable.symbol_number_pad_24),
contentDescription = null
)
}
}
}
},
keyboardOptions = KeyboardOptions(
keyboardType = if (showKeyboard) {
KeyboardType.Number
} else {
KeyboardType.Text
}
),
shape = RoundedCornerShape(32.dp),
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.padding(bottom = 18.dp)
.padding(horizontal = 16.dp)
.fillMaxWidth()
.defaultMinSize(minHeight = 54.dp)
.focusRequester(focusRequester),
visualTransformation = VisualTransformation.None,
colors = TextFieldDefaults.colors(
// TODO move to SignalTheme
focusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceVariant,
disabledContainerColor = MaterialTheme.colorScheme.surfaceVariant,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent
)
)
}
@DayNightPreviews
@Composable
private fun ScreenPreview() {
Previews.Preview {
CountryCodePickerScreen(
state = CountryCodeState(
countryList = mutableListOf(
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, "US"),
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 5, "CA")
)
),
onEvent = {}
)
}
}
@DayNightPreviews
@Composable
private fun LoadingScreenPreview() {
Previews.Preview {
CountryCodePickerScreen(
state = CountryCodeState(
countryList = emptyList()
),
onEvent = {}
)
}
}
@LargeFontPreviews
@Composable
private fun LargeFontScreenPreview() {
Previews.Preview {
CountryCodePickerScreen(
state = CountryCodeState(
countryList = mutableListOf(
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, "US"),
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 5, "CA")
)
),
onEvent = {}
)
}
}
@@ -0,0 +1,12 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.countrycode
sealed interface CountryCodePickerScreenEvents {
data class Search(val query: String) : CountryCodePickerScreenEvents
data class CountrySelected(val country: Country) : CountryCodePickerScreenEvents
data object Dismissed : CountryCodePickerScreenEvents
}
@@ -0,0 +1,105 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.countrycode
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import org.signal.core.ui.navigation.ResultEventBus
import org.signal.registration.RegistrationFlowEvent
import org.signal.registration.screens.util.navigateBack
/**
* View model for the country code picker screen.
* Handles search filtering and country selection, emitting results back via [ResultEventBus].
*/
class CountryCodePickerViewModel(
private val repository: CountryCodePickerRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val resultBus: ResultEventBus,
private val resultKey: String,
initialCountry: Country? = null
) : ViewModel() {
private val _state = MutableStateFlow(CountryCodeState())
val state: StateFlow<CountryCodeState> = _state.asStateFlow()
init {
loadCountries(initialCountry)
}
fun onEvent(event: CountryCodePickerScreenEvents) {
when (event) {
is CountryCodePickerScreenEvents.Search -> applySearchEvent(event.query)
is CountryCodePickerScreenEvents.CountrySelected -> {
resultBus.sendResult(resultKey, event.country)
parentEventEmitter.navigateBack()
}
is CountryCodePickerScreenEvents.Dismissed -> {
parentEventEmitter.navigateBack()
}
}
}
private fun applySearchEvent(filterBy: String) {
if (filterBy.isEmpty()) {
_state.update {
it.copy(
query = filterBy,
filteredList = emptyList()
)
}
} else {
_state.update {
it.copy(
query = filterBy,
filteredList = _state.value.countryList.filter { country: Country ->
country.name.contains(filterBy, ignoreCase = true) ||
country.countryCode.toString().contains(filterBy.removePrefix("+")) ||
(filterBy.equals("usa", ignoreCase = true) && country.name.equals("United States", ignoreCase = true))
}
)
}
}
}
private fun loadCountries(initialCountry: Country? = null) {
viewModelScope.launch {
val countryList = repository.getCountries()
val commonCountryList = repository.getCommonCountries()
val startingIndex = if (initialCountry == null || commonCountryList.contains(initialCountry)) {
0
} else {
countryList.indexOf(initialCountry) + commonCountryList.size
}
_state.update {
it.copy(
countryList = countryList,
commonCountryList = commonCountryList,
startingIndex = startingIndex
)
}
}
}
class Factory(
private val repository: CountryCodePickerRepository,
private val parentEventEmitter: (RegistrationFlowEvent) -> Unit,
private val resultBus: ResultEventBus,
private val resultKey: String,
private val initialCountry: Country? = null
) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T {
return CountryCodePickerViewModel(repository, parentEventEmitter, resultBus, resultKey, initialCountry) as T
}
}
}
@@ -0,0 +1,17 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.countrycode
/**
* State managed by [CountryCodePickerViewModel]. 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(),
val startingIndex: Int = 0
)
@@ -10,13 +10,19 @@ package org.signal.registration.screens.permissions
import android.Manifest
import androidx.compose.foundation.Image
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
@@ -31,8 +37,8 @@ import com.google.accompanist.permissions.MultiplePermissionsState
import org.signal.core.ui.compose.Buttons
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
import org.signal.registration.R
import org.signal.registration.screens.shared.RegistrationScreen
import org.signal.registration.screens.util.MockMultiplePermissionsState
import org.signal.registration.screens.util.MockPermissionsState
import org.signal.registration.test.TestTags
@@ -53,79 +59,119 @@ fun PermissionsScreen(
) {
val permissions = permissionsState.permissions.map { it.permission }
RegistrationScreen(
title = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
modifier = modifier.testTag(TestTags.PERMISSIONS_SCREEN),
bottomContent = {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
Surface(modifier = modifier.testTag(TestTags.PERMISSIONS_SCREEN)) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(scrollState)
.weight(weight = 1f, fill = false)
.padding(bottom = 16.dp)
.horizontalGutters()
) {
TextButton(
modifier = Modifier
.weight(weight = 1f, fill = false)
.testTag(TestTags.PERMISSIONS_NOT_NOW_BUTTON),
onClick = onProceed
) {
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
Spacer(Modifier.height(40.dp))
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__allow_permissions),
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.fillMaxWidth()
)
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__to_help_you_message_people_you_know),
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
Spacer(modifier = Modifier.height(40.dp))
if (permissions.any { it == Manifest.permission.POST_NOTIFICATIONS }) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
)
}
Spacer(modifier = Modifier.size(24.dp))
if (permissions.any { it == Manifest.permission.READ_CONTACTS || it == Manifest.permission.WRITE_CONTACTS }) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact),
title = stringResource(id = R.string.GrantPermissionsFragment__contacts),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know)
)
}
Buttons.LargeTonal(
modifier = Modifier.testTag(TestTags.PERMISSIONS_NEXT_BUTTON),
onClick = {
permissionsState.launchMultiplePermissionRequest()
onProceed()
if (permissions.any {
it == Manifest.permission.READ_EXTERNAL_STORAGE ||
it == Manifest.permission.WRITE_EXTERNAL_STORAGE ||
it == Manifest.permission.READ_MEDIA_IMAGES ||
it == Manifest.permission.READ_MEDIA_VIDEO ||
it == Manifest.permission.READ_MEDIA_AUDIO
}
) {
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__next)
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_file),
title = stringResource(id = R.string.GrantPermissionsFragment__storage),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files)
)
}
if (permissions.any { it == Manifest.permission.READ_PHONE_STATE || it == Manifest.permission.READ_PHONE_NUMBERS }) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone),
title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier)
)
}
}
}
) {
if (permissions.any { it == Manifest.permission.POST_NOTIFICATIONS }) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_notification),
title = stringResource(id = R.string.GrantPermissionsFragment__notifications),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__get_notified_when)
)
}
if (permissions.any { it == Manifest.permission.READ_CONTACTS || it == Manifest.permission.WRITE_CONTACTS }) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_contact),
title = stringResource(id = R.string.GrantPermissionsFragment__contacts),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__find_people_you_know)
)
}
Surface(
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.padding(top = 8.dp, bottom = 24.dp)
.horizontalGutters()
) {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier.fillMaxWidth()
) {
TextButton(
modifier = Modifier
.weight(weight = 1f, fill = false)
.testTag(TestTags.PERMISSIONS_NOT_NOW_BUTTON),
onClick = onProceed
) {
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__not_now)
)
}
if (permissions.any {
it == Manifest.permission.READ_EXTERNAL_STORAGE ||
it == Manifest.permission.WRITE_EXTERNAL_STORAGE ||
it == Manifest.permission.READ_MEDIA_IMAGES ||
it == Manifest.permission.READ_MEDIA_VIDEO ||
it == Manifest.permission.READ_MEDIA_AUDIO
Spacer(modifier = Modifier.size(24.dp))
Buttons.LargeTonal(
modifier = Modifier.testTag(TestTags.PERMISSIONS_NEXT_BUTTON),
onClick = {
permissionsState.launchMultiplePermissionRequest()
onProceed()
}
) {
Text(
text = stringResource(id = R.string.GrantPermissionsFragment__next)
)
}
}
}
}
) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_file),
title = stringResource(id = R.string.GrantPermissionsFragment__storage),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__send_photos_videos_and_files)
)
}
if (permissions.any { it == Manifest.permission.READ_PHONE_STATE || it == Manifest.permission.READ_PHONE_NUMBERS }) {
PermissionRow(
imageVector = ImageVector.vectorResource(id = R.drawable.permission_phone),
title = stringResource(id = R.string.GrantPermissionsFragment__phone_calls),
subtitle = stringResource(id = R.string.GrantPermissionsFragment__make_registering_easier)
)
}
}
}
@@ -87,9 +87,8 @@ fun PhoneNumberScreen(
@Composable
private fun ScreenContent(state: PhoneNumberEntryState, onEvent: (PhoneNumberEntryScreenEvents) -> Unit) {
// TODO: These should come from state once country picker is implemented
var selectedCountry by remember { mutableStateOf("United States") }
var selectedCountryEmoji by remember { mutableStateOf("🇺🇸") }
val selectedCountry = state.countryName
val selectedCountryEmoji = state.countryEmoji
// Track the phone number text field value with cursor position
var phoneNumberTextFieldValue by remember { mutableStateOf(TextFieldValue(state.formattedNumber)) }
@@ -8,6 +8,7 @@ package org.signal.registration.screens.phonenumber
sealed interface PhoneNumberEntryScreenEvents {
data class CountryCodeChanged(val value: String) : PhoneNumberEntryScreenEvents
data class PhoneNumberChanged(val value: String) : PhoneNumberEntryScreenEvents
data class CountrySelected(val countryCode: Int, val regionCode: String, val countryName: String, val countryEmoji: String) : PhoneNumberEntryScreenEvents
data object PhoneNumberSubmitted : PhoneNumberEntryScreenEvents
data object CountryPicker : PhoneNumberEntryScreenEvents
data class CaptchaCompleted(val token: String) : PhoneNumberEntryScreenEvents
@@ -13,6 +13,8 @@ import kotlin.time.Duration
data class PhoneNumberEntryState(
val regionCode: String = "US",
val countryCode: String = "1",
val countryName: String = "United States",
val countryEmoji: String = "\uD83C\uDDFA\uD83C\uDDF8",
val nationalNumber: String = "",
val formattedNumber: String = "",
val sessionE164: String? = null,
@@ -74,6 +74,9 @@ class PhoneNumberEntryViewModel(
is PhoneNumberEntryScreenEvents.CountryCodeChanged -> {
stateEmitter(applyCountryCodeChanged(state, event.value))
}
is PhoneNumberEntryScreenEvents.CountrySelected -> {
stateEmitter(applyCountrySelected(state, event.countryCode, event.regionCode, event.countryName, event.countryEmoji))
}
is PhoneNumberEntryScreenEvents.PhoneNumberChanged -> {
stateEmitter(applyPhoneNumberChanged(state, event.value))
}
@@ -105,6 +108,22 @@ class PhoneNumberEntryViewModel(
)
}
private fun applyCountrySelected(state: PhoneNumberEntryState, countryCode: Int, regionCode: String, countryName: String, countryEmoji: String): PhoneNumberEntryState {
val countryCodeStr = countryCode.toString()
if (countryCodeStr == state.countryCode && regionCode == state.regionCode) return state
formatter = phoneNumberUtil.getAsYouTypeFormatter(regionCode)
val formattedNumber = formatNumber(state.nationalNumber)
return state.copy(
countryCode = countryCodeStr,
regionCode = regionCode,
countryName = countryName,
countryEmoji = countryEmoji,
formattedNumber = formattedNumber
)
}
private fun applyCountryCodeChanged(state: PhoneNumberEntryState, countryCode: String): PhoneNumberEntryState {
// Only allow digits, max 3 characters
val sanitized = countryCode.filter { it.isDigit() }.take(3)
@@ -1,110 +0,0 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.shared
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import org.signal.core.ui.compose.DayNightPreviews
import org.signal.core.ui.compose.Previews
import org.signal.core.ui.compose.horizontalGutters
/**
* A base framework for rendering the various registration screens.
*/
@Composable
fun RegistrationScreen(
title: String,
subtitle: String,
bottomContent: @Composable (BoxScope.() -> Unit),
modifier: Modifier = Modifier,
mainContent: @Composable ColumnScope.() -> Unit
) {
Surface(modifier = modifier) {
Column(
verticalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
.fillMaxHeight()
) {
val scrollState = rememberScrollState()
Column(
modifier = Modifier
.verticalScroll(scrollState)
.weight(weight = 1f, fill = false)
.padding(bottom = 16.dp)
.horizontalGutters()
) {
Spacer(Modifier.height(40.dp))
Text(
text = title,
style = MaterialTheme.typography.headlineMedium,
modifier = Modifier.fillMaxWidth()
)
Text(
text = subtitle,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(top = 16.dp)
)
Spacer(modifier = Modifier.height(40.dp))
mainContent()
}
Surface(
shadowElevation = if (scrollState.canScrollForward) 8.dp else 0.dp,
modifier = Modifier.fillMaxWidth()
) {
Box(
modifier = Modifier
.padding(top = 8.dp, bottom = 24.dp)
.horizontalGutters()
) {
bottomContent()
}
}
}
}
}
@DayNightPreviews
@Composable
private fun RegistrationScreenPreview() {
Previews.Preview {
RegistrationScreen(
title = "Title",
subtitle = "Subtitle",
bottomContent = {
TextButton(onClick = {}) {
Text("Bottom Button")
}
}
) {
Text("Main content")
}
}
}
@@ -0,0 +1,36 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FF000000"
android:pathData="M8.38 4.75c0 0.9-0.73 1.63-1.63 1.63-0.9 0-1.63-0.73-1.63-1.63 0-0.9 0.73-1.63 1.63-1.63 0.9 0 1.63 0.73 1.63 1.63Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M12 6.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M17.25 6.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M12 21.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M13.63 14.75c0 0.9-0.73 1.63-1.63 1.63-0.9 0-1.63-0.73-1.63-1.63 0-0.9 0.73-1.63 1.63-1.63 0.9 0 1.63 0.73 1.63 1.63Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M17.25 16.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M8.38 14.75c0 0.9-0.73 1.63-1.63 1.63-0.9 0-1.63-0.73-1.63-1.63 0-0.9 0.73-1.63 1.63-1.63 0.9 0 1.63 0.73 1.63 1.63Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M12 11.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M18.88 9.75c0 0.9-0.73 1.63-1.63 1.63-0.9 0-1.63-0.73-1.63-1.63 0-0.9 0.73-1.63 1.63-1.63 0.9 0 1.63 0.73 1.63 1.63Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M6.75 11.38c0.9 0 1.63-0.73 1.63-1.63 0-0.9-0.73-1.63-1.63-1.63-0.9 0-1.63 0.73-1.63 1.63 0 0.9 0.73 1.63 1.63 1.63Z"/>
</vector>
@@ -32,4 +32,14 @@
<string name="GrantPermissionsFragment__storage">Storage</string>
<!-- Storage permission row description -->
<string name="GrantPermissionsFragment__send_photos_videos_and_files">Send photos, videos and files from your device.</string>
<!-- CountryCodePickerScreen -->
<!-- Title of the country code selection screen -->
<string name="CountryCodeSelectScreen__your_country">Your country</string>
<!-- Close button content description -->
<string name="CountryCodeSelectScreen__close">Close</string>
<!-- Search bar hint text -->
<string name="CountryCodeSelectScreen__search_by">Search by name or number</string>
<!-- Fallback text for countries with no name -->
<string name="CountryCodeSelectScreen__unknown_country">Unknown country</string>
</resources>
@@ -0,0 +1,286 @@
/*
* Copyright 2025 Signal Messenger, LLC
* SPDX-License-Identifier: AGPL-3.0-only
*/
package org.signal.registration.screens.countrycode
import assertk.assertThat
import assertk.assertions.contains
import assertk.assertions.hasSize
import assertk.assertions.isEqualTo
import assertk.assertions.isGreaterThan
import assertk.assertions.isNotEmpty
import assertk.assertions.isNull
import assertk.assertions.isTrue
import io.mockk.coEvery
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import org.junit.After
import org.junit.Before
import org.junit.Test
import org.signal.core.ui.navigation.ResultEventBus
import org.signal.registration.RegistrationFlowEvent
@OptIn(ExperimentalCoroutinesApi::class)
class CountryCodePickerViewModelTest {
private lateinit var mockRepository: CountryCodePickerRepository
private lateinit var emittedEvents: MutableList<RegistrationFlowEvent>
private lateinit var parentEventEmitter: (RegistrationFlowEvent) -> Unit
private lateinit var resultBus: ResultEventBus
private val resultKey = "test_country_result"
private val testDispatcher = StandardTestDispatcher()
private val testCountries = listOf(
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US"),
Country("\uD83C\uDDE8\uD83C\uDDE6", "Canada", 1, "CA"),
Country("\uD83C\uDDE9\uD83C\uDDEA", "Germany", 49, "DE"),
Country("\uD83C\uDDEC\uD83C\uDDE7", "United Kingdom", 44, "GB"),
Country("\uD83C\uDDEE\uD83C\uDDF3", "India", 91, "IN"),
Country("\uD83C\uDDF3\uD83C\uDDF1", "Netherlands", 31, "NL"),
Country("\uD83C\uDDFA\uD83C\uDDE6", "Ukraine", 380, "UA")
)
private val testCommonCountries = listOf(
Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US"),
Country("\uD83C\uDDE9\uD83C\uDDEA", "Germany", 49, "DE"),
Country("\uD83C\uDDEE\uD83C\uDDF3", "India", 91, "IN"),
Country("\uD83C\uDDF3\uD83C\uDDF1", "Netherlands", 31, "NL"),
Country("\uD83C\uDDFA\uD83C\uDDE6", "Ukraine", 380, "UA")
)
@Before
fun setup() {
Dispatchers.setMain(testDispatcher)
mockRepository = mockk(relaxed = true)
emittedEvents = mutableListOf()
parentEventEmitter = { event -> emittedEvents.add(event) }
resultBus = ResultEventBus()
coEvery { mockRepository.getCountries() } returns testCountries
coEvery { mockRepository.getCommonCountries() } returns testCommonCountries
}
@After
fun tearDown() {
Dispatchers.resetMain()
}
private fun createViewModel(initialCountry: Country? = null): CountryCodePickerViewModel {
return CountryCodePickerViewModel(mockRepository, parentEventEmitter, resultBus, resultKey, initialCountry)
}
// ==================== Initialization Tests ====================
@Test
fun `initial state has empty lists before loading completes`() {
val state = CountryCodeState()
assertThat(state.query).isEqualTo("")
assertThat(state.countryList).isEqualTo(emptyList())
assertThat(state.commonCountryList).isEqualTo(emptyList())
assertThat(state.filteredList).isEqualTo(emptyList())
assertThat(state.startingIndex).isEqualTo(0)
}
@Test
fun `loadCountries populates country list and common country list`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.countryList).isNotEmpty()
assertThat(state.commonCountryList).isNotEmpty()
}
@Test
fun `loadCountries includes United States in country list`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
val state = viewModel.state.value
val us = state.countryList.find { it.regionCode == "US" }
assertThat(us != null).isTrue()
assertThat(us!!.countryCode).isEqualTo(1)
}
@Test
fun `loadCountries includes common countries`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
val state = viewModel.state.value
val commonRegionCodes = state.commonCountryList.map { it.regionCode }
assertThat(commonRegionCodes).contains("US")
assertThat(commonRegionCodes).contains("DE")
assertThat(commonRegionCodes).contains("IN")
}
@Test
fun `loadCountries with initialCountry not in common list sets starting index`() = runTest {
val canada = testCountries.find { it.regionCode == "CA" }!!
val viewModel = createViewModel(initialCountry = canada)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.startingIndex).isGreaterThan(0)
}
@Test
fun `loadCountries with common initialCountry keeps starting index at 0`() = runTest {
val us = testCommonCountries.find { it.regionCode == "US" }!!
val viewModel = createViewModel(initialCountry = us)
advanceUntilIdle()
val state = viewModel.state.value
assertThat(state.startingIndex).isEqualTo(0)
}
@Test
fun `loadCountries with null initialCountry keeps starting index at 0`() = runTest {
val viewModel = createViewModel(initialCountry = null)
advanceUntilIdle()
assertThat(viewModel.state.value.startingIndex).isEqualTo(0)
}
// ==================== Search Tests ====================
@Test
fun `Search event filters countries by name`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.onEvent(CountryCodePickerScreenEvents.Search("United"))
val state = viewModel.state.value
assertThat(state.query).isEqualTo("United")
assertThat(state.filteredList).isNotEmpty()
assertThat(state.filteredList.all { it.name.contains("United", ignoreCase = true) }).isTrue()
}
@Test
fun `Search event filters countries by country code`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.onEvent(CountryCodePickerScreenEvents.Search("49"))
val state = viewModel.state.value
assertThat(state.filteredList).isNotEmpty()
assertThat(state.filteredList.any { it.countryCode == 49 }).isTrue()
}
@Test
fun `Search event filters countries by country code with plus prefix`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.onEvent(CountryCodePickerScreenEvents.Search("+49"))
val state = viewModel.state.value
assertThat(state.filteredList).isNotEmpty()
assertThat(state.filteredList.any { it.countryCode == 49 }).isTrue()
}
@Test
fun `Search event is case insensitive`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.onEvent(CountryCodePickerScreenEvents.Search("germany"))
val state = viewModel.state.value
assertThat(state.filteredList).isNotEmpty()
assertThat(state.filteredList.any { it.regionCode == "DE" }).isTrue()
}
@Test
fun `Search for USA matches United States`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.onEvent(CountryCodePickerScreenEvents.Search("usa"))
val state = viewModel.state.value
assertThat(state.filteredList).isNotEmpty()
assertThat(state.filteredList.any { it.regionCode == "US" }).isTrue()
}
@Test
fun `Empty search clears filtered list`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
// First, search for something
viewModel.onEvent(CountryCodePickerScreenEvents.Search("United"))
assertThat(viewModel.state.value.filteredList).isNotEmpty()
// Then clear it
viewModel.onEvent(CountryCodePickerScreenEvents.Search(""))
val state = viewModel.state.value
assertThat(state.query).isEqualTo("")
assertThat(state.filteredList).isEqualTo(emptyList())
}
@Test
fun `Search with no matches returns empty filtered list`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.onEvent(CountryCodePickerScreenEvents.Search("xyznonexistent"))
val state = viewModel.state.value
assertThat(state.filteredList).isEqualTo(emptyList())
}
// ==================== CountrySelected Tests ====================
@Test
fun `CountrySelected sends result via result bus and navigates back`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
val country = Country("\uD83C\uDDFA\uD83C\uDDF8", "United States", 1, "US")
viewModel.onEvent(CountryCodePickerScreenEvents.CountrySelected(country))
val result = resultBus.channelMap[resultKey]?.tryReceive()?.getOrNull()
assertThat(result).isEqualTo(country)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
@Test
fun `Dismissed does not send result to result bus`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.onEvent(CountryCodePickerScreenEvents.Dismissed)
val result = resultBus.channelMap[resultKey]?.tryReceive()?.getOrNull()
assertThat(result).isNull()
}
// ==================== Dismissed Tests ====================
@Test
fun `Dismissed navigates back`() = runTest {
val viewModel = createViewModel()
advanceUntilIdle()
viewModel.onEvent(CountryCodePickerScreenEvents.Dismissed)
assertThat(emittedEvents).hasSize(1)
assertThat(emittedEvents.first()).isEqualTo(RegistrationFlowEvent.NavigateBack)
}
}