From 281d2595fc9206a8986eeaf5ab51a572631b6304 Mon Sep 17 00:00:00 2001 From: Uladzislau Hramyka Date: Sun, 18 Oct 2026 16:27:54 +0300 Subject: [PATCH] fix(auth): stabilize phone input editing and country codes Keep phone values separate from display formatting to preserve selections, cursor positions, and digits during edits. Insert the selected dial code for national input, keep national digits when changing countries, and align placeholders with the exact country entry. Validate with 138 unit tests and 9 phone input interaction tests on Pixel 10 Pro XL. --- .../org/monogram/PhoneInputEditingTest.kt | 156 ++++++++++++++++++ .../monogram/core/common/CountryManager.kt | 60 +++---- .../common/CountryManagerPhoneInputTest.kt | 77 +++++++++ .../monogram/feature/auth/ui/PhoneInput.kt | 154 +++++++++++------ .../feature/auth/ui/PhoneInputTest.kt | 156 ++++++++++++++++++ 5 files changed, 518 insertions(+), 85 deletions(-) create mode 100644 app/src/androidTest/java/org/monogram/PhoneInputEditingTest.kt create mode 100644 core/common/src/test/java/org/monogram/core/common/CountryManagerPhoneInputTest.kt create mode 100644 feature/auth/src/test/java/org/monogram/feature/auth/ui/PhoneInputTest.kt diff --git a/app/src/androidTest/java/org/monogram/PhoneInputEditingTest.kt b/app/src/androidTest/java/org/monogram/PhoneInputEditingTest.kt new file mode 100644 index 000000000..1c3e6a66f --- /dev/null +++ b/app/src/androidTest/java/org/monogram/PhoneInputEditingTest.kt @@ -0,0 +1,156 @@ +package org.monogram + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.hasSetTextAction +import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performImeAction +import androidx.compose.ui.test.performKeyInput +import androidx.compose.ui.test.performTextClearance +import androidx.compose.ui.test.performTextInput +import androidx.compose.ui.test.performTextInputSelection +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.test.pressKey +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.monogram.core.ui.theme.MonogramTheme +import org.monogram.feature.auth.ui.PhoneInput +import org.monogram.feature.auth.ui.PhoneInputTags + +class PhoneInputEditingTest { + @get:Rule val compose = createAndroidComposeRule() + + private var phone by mutableStateOf("") + private var imeActions = 0 + + @Test + fun clearedGuamInputShowsOnlyTheNationalPlaceholder() { + launch() + chooseCountry("Guam") + field().performTextClearance() + + compose.onNodeWithText("000 0000").assertIsDisplayed() + compose.onNodeWithText("+1 000-000-0000").assertDoesNotExist() + field().performTextInput("5") + compose.runOnIdle { assertEquals("+16715", phone) } + } + + @Test + fun selectionRangeCanBeReplaced() { + launch("+12025550123") + field().performTextInputSelection(TextRange(3, 6), relativeToOriginalText = true) + + assertEquals(TextRange(3, 6), field().fetchSemanticsNode().config[SemanticsProperties.TextSelectionRange]) + field().performTextInput("999") + compose.runOnIdle { assertEquals("+12999550123", phone) } + } + + @Test + fun changingCountryKeepsTheNationalNumber() { + launch("+375291234567") + chooseCountry("Germany") + + compose.runOnIdle { assertEquals("+49291234567", phone) } + } + + @Test + fun internationalCodeCanBeEnteredOneCharacterAtATime() { + launch() + field().performTextInput("+") + compose.runOnIdle { assertEquals("+", phone) } + field().performTextInput("4") + field().performTextInput("9") + + compose.runOnIdle { assertEquals("+49", phone) } + } + + @Test + fun nextImeActionReachesTheCaller() { + launch(imeAction = ImeAction.Next) + field().performImeAction() + + compose.runOnIdle { assertEquals(1, imeActions) } + } + + @Test + fun internationalPasteKeepsAllDigits() { + launch() + field().performTextInput("+1 (202) 555-0123") + + compose.runOnIdle { assertEquals("+12025550123", phone) } + } + + @Test + fun backspaceAndForwardDeleteWorkAcrossFormattingSeparators() { + launch("+12025550123") + field().performTextInputSelection(TextRange(5), relativeToOriginalText = true) + field().performKeyInput { pressKey(Key.Backspace) } + compose.runOnIdle { assertEquals("+1205550123", phone) } + field().performKeyInput { pressKey(Key.Delete) } + compose.runOnIdle { assertEquals("+120550123", phone) } + } + + @Test + fun replacingTheWholeNumberWithNationalDigitsKeepsSelectedCountry() { + launch() + chooseCountry("Belarus") + field().performTextInput("291234567") + field().performTextInputSelection(TextRange(0, 13), relativeToOriginalText = true) + field().performTextInput("331234567") + + compose.runOnIdle { assertEquals("+375331234567", phone) } + } + + @Test + fun externalValueChangesMoveTheCursorToTheNewNumber() { + launch("+12025550123") + field().performTextInputSelection(TextRange(2), relativeToOriginalText = true) + compose.runOnIdle { phone = "+49" } + field().performTextInput("1") + + compose.runOnIdle { assertEquals("+491", phone) } + } + + private fun field() = compose.onNodeWithTag(PhoneInputTags.FIELD) + + private fun chooseCountry(name: String) { + compose.onNodeWithTag(PhoneInputTags.COUNTRY).performClick() + val search = compose.activity.getString(org.monogram.feature.auth.R.string.auth_country_search_hint) + compose.onNodeWithText(search).performTextInput(name) + compose.onNode(hasText(name) and !hasSetTextAction()).performClick() + } + + private fun launch(initial: String = "", imeAction: ImeAction = ImeAction.Done) { + phone = initial + compose.activityRule.scenario.onActivity { activity -> + activity.window.addFlags( + android.view.WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON or + android.view.WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or + android.view.WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON, + ) + } + compose.setContent { + MonogramTheme { + PhoneInput( + value = phone, + onValueChange = { phone = it }, + label = "Phone number", + placeholder = "49 151 1234567", + imeAction = imeAction, + onImeAction = { imeActions++ }, + ) + } + } + } +} diff --git a/core/common/src/main/java/org/monogram/core/common/CountryManager.kt b/core/common/src/main/java/org/monogram/core/common/CountryManager.kt index 25f267275..df06ea35f 100644 --- a/core/common/src/main/java/org/monogram/core/common/CountryManager.kt +++ b/core/common/src/main/java/org/monogram/core/common/CountryManager.kt @@ -70,23 +70,16 @@ object CountryManager { fun countryForTypedPhone(phone: String, fallback: Country?): Country? { val digits = phone.filter { it.isDigit() } if (digits.isEmpty()) return fallback - if (fallback != null && (digits.startsWith(fallback.code) || isNationalNumber(digits, fallback))) { + val detected = countryForPhone(digits) ?: return fallback + if (fallback != null && digits.startsWith(fallback.code) && + fallback.code.length >= detected.code.length && + !(detected.code == "7" && digits.length > 1) + ) { return fallback } - return countryForPhone(digits) ?: fallback + return detected } - fun phoneValueForTypedDigits(digits: String, fallback: Country?): String { - if (digits.isEmpty()) return "" - if (fallback != null && !digits.startsWith(fallback.code) && isNationalNumber(digits, fallback)) { - return "+${fallback.code}$digits" - } - return "+$digits" - } - - private fun isNationalNumber(digits: String, country: Country): Boolean = - isValidPhoneNumber("+${country.code}$digits", country.iso) - fun formatPhoneNumber(raw: String): String { val digits = raw.filter { it.isDigit() } val rule = customRules.firstOrNull { digits.startsWith(it.code) } @@ -173,26 +166,29 @@ object CountryManager { fun formatAsYouType(raw: String, country: Country): String { val digits = raw.filter { it.isDigit() } - if (digits.isEmpty()) return "" - - val international = formatPhoneNumber("+$digits") - if (international.filter { it.isDigit() } == digits) return international - - if (!digits.startsWith(country.code) && isPartialCountryCode(digits)) return "+$digits" + if (digits.isEmpty()) return if (raw.trimStart().startsWith("+")) "+" else "" - val local = digits.removePrefix(country.code) - return "+${country.code} ${formatPartialPhoneNumber(country.iso, local)}".trimEnd() + val rule = customRules.firstOrNull { digits.startsWith(it.code) } + val formatted = when { + rule?.format == false -> "+$digits" + rule?.code == "888" -> format888(digits) + else -> { + val formatter = phoneUtil.getAsYouTypeFormatter(country.iso) + var result = formatter.inputDigit('+') + digits.forEach { result = formatter.inputDigit(it) } + result + } + } + return formatted.takeIf { it.filter(Char::isDigit) == digits } ?: "+$digits" } - private fun isPartialCountryCode(digits: String): Boolean = - countryTable.any { it.code.length > digits.length && it.code.startsWith(digits) } - - fun exampleNumber(iso: String): String? = runCatching { - val example = phoneUtil.getExampleNumberForType(iso, PhoneNumberUtil.PhoneNumberType.MOBILE) - phoneUtil.format(example, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL).let { formatted -> - formatted.substringBefore(" ") + " " + - formatted.substringAfter(" ").replace(Regex("\\d"), "0") - } + fun exampleNumber(country: Country): String? = runCatching { + country.mask?.let { return "+${country.code} ${it.replace('X', '0')}" } + val example = phoneUtil.getExampleNumberForType(country.iso, PhoneNumberUtil.PhoneNumberType.MOBILE) + val formatted = phoneUtil.format(example, PhoneNumberUtil.PhoneNumberFormat.INTERNATIONAL) + val national = formatted.removePrefix("+${country.code}").trim() + if (national.startsWith("+")) return null + "+${country.code} ${national.replace(Regex("\\d"), "0")}" }.getOrNull() fun simIso(context: Context): String? { @@ -201,9 +197,7 @@ object CountryManager { } fun resolveIso(simIso: String?, deviceIso: String?): String { - val candidate = simIso?.trim()?.takeIf { it.isNotEmpty() }?.uppercase() - ?: deviceIso?.trim()?.takeIf { it.isNotEmpty() }?.uppercase() - return if (candidate != null && countryForIso(candidate) != null) candidate else fallbackIso + return countryForIso(simIso)?.iso ?: countryForIso(deviceIso)?.iso ?: fallbackIso } private fun format888(digits: String): String { diff --git a/core/common/src/test/java/org/monogram/core/common/CountryManagerPhoneInputTest.kt b/core/common/src/test/java/org/monogram/core/common/CountryManagerPhoneInputTest.kt new file mode 100644 index 000000000..0b70c00a3 --- /dev/null +++ b/core/common/src/test/java/org/monogram/core/common/CountryManagerPhoneInputTest.kt @@ -0,0 +1,77 @@ +package org.monogram.core.common + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CountryManagerPhoneInputTest { + @Test + fun guamExampleUsesTheSameDialCodeAsThePicker() { + val country = checkNotNull(CountryManager.countryForIso("GU")) + + assertEquals("+1671 000 0000", CountryManager.exampleNumber(country)) + } + + @Test + fun partialNumbersAreFormattedAsTheyAreTyped() { + val country = checkNotNull(CountryManager.countryForIso("US")) + + assertEquals("+1 202", CountryManager.formatAsYouType("+1202", country)) + } + + @Test + fun explicitInternationalNumbersAreNotInterpretedAsNationalNumbers() { + val country = checkNotNull(CountryManager.countryForIso("BY")) + + assertEquals("DE", CountryManager.countryForTypedPhone("+491511234567", country)?.iso) + } + + @Test + fun moreSpecificDialCodeOverridesDefaultCountry() { + val country = checkNotNull(CountryManager.countryForIso("US")) + + assertEquals("GU", CountryManager.countryForTypedPhone("+16715551234", country)?.iso) + } + + @Test + fun formattingPreservesEveryDigitIncludingLongAnonymousNumbers() { + val country = checkNotNull(CountryManager.countryForIso("FT")) + val number = "+888123456789012" + + assertEquals(number.filter(Char::isDigit), CountryManager.formatAsYouType(number, country).filter(Char::isDigit)) + } + + @Test + fun barePlusIsPreservedWhileEnteringAnInternationalCode() { + val country = checkNotNull(CountryManager.countryForIso("BY")) + + assertEquals("+", CountryManager.formatAsYouType("+", country)) + } + + @Test + fun examplesNeverRepeatAnInternationalPrefixInTheNationalPart() { + CountryManager.countries().forEach { country -> + CountryManager.exampleNumber(country)?.let { example -> + assertTrue("${country.iso}: $example", example.startsWith("+${country.code} ")) + assertTrue("${country.iso}: $example", '+' !in example.substringAfter(' ')) + } + } + } + + @Test + fun invalidSimCountryFallsBackToDeviceCountry() { + assertEquals("BY", CountryManager.resolveIso("ZZ", "BY")) + assertEquals("BY", CountryManager.resolveIso(null, "by")) + assertEquals("US", CountryManager.resolveIso("ZZ", "ZZ")) + } + + @Test + fun sharedRussianAndKazakhCodesFollowTheTypedPrefix() { + val russia = checkNotNull(CountryManager.countryForIso("RU")) + val kazakhstan = checkNotNull(CountryManager.countryForIso("KZ")) + + assertEquals("KZ", CountryManager.countryForTypedPhone("+77", russia)?.iso) + assertEquals("RU", CountryManager.countryForTypedPhone("+79", kazakhstan)?.iso) + assertEquals("KZ", CountryManager.countryForTypedPhone("+7", kazakhstan)?.iso) + } +} diff --git a/feature/auth/src/main/java/org/monogram/feature/auth/ui/PhoneInput.kt b/feature/auth/src/main/java/org/monogram/feature/auth/ui/PhoneInput.kt index 85ba6e290..b9a6dc171 100644 --- a/feature/auth/src/main/java/org/monogram/feature/auth/ui/PhoneInput.kt +++ b/feature/auth/src/main/java/org/monogram/feature/auth/ui/PhoneInput.kt @@ -31,12 +31,17 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.OffsetMapping import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.text.input.TransformedText +import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import org.monogram.core.common.Country import org.monogram.core.common.CountryManager @@ -65,52 +70,37 @@ fun PhoneInput( val defaultIso = remember(context) { CountryManager.resolveIso(CountryManager.simIso(context), Locale.getDefault().country) } - var pickedIso by rememberSaveable { mutableStateOf(null) } + var pickedKey by rememberSaveable { mutableStateOf(null) } var pickerVisible by rememberSaveable { mutableStateOf(false) } val digits = value.filter { it.isDigit() } - val picked = CountryManager.countryForIso(pickedIso) + val picked = CountryManager.countries().firstOrNull { it.pickerKey == pickedKey } val fallback = picked ?: CountryManager.countryForIso(defaultIso) val country = CountryManager.countryForTypedPhone(digits, fallback) ?: fallback ?: CountryManager.countries().firstOrNull() ?: return - val display = CountryManager.formatAsYouType(value, country) - val example = remember(country.iso, placeholder) { - CountryManager.exampleNumber(country.iso) + val example = remember(country.pickerKey, placeholder) { + CountryManager.exampleNumber(country) ?.removePrefix("+${country.code}") ?.trim() ?.takeIf { it.isNotEmpty() } ?: placeholder } - var caret by rememberSaveable { mutableStateOf(display.length) } - val field = TextFieldValue( - text = display, - selection = TextRange(caret.coerceIn(0, display.length)), - ) + var editor by rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue(value, TextRange(value.length))) + } + val field = if (editor.text == value) editor else TextFieldValue(value, TextRange(value.length)) + val transformation = remember(country) { PhoneNumberTransformation(country) } val dialCodeLabel = stringResource(R.string.auth_dial_code) val countryLabel = stringResource(R.string.auth_country_selected, country.name) OutlinedTextField( value = field, - onValueChange = { edit -> - val editCaret = edit.selection.end.coerceIn(0, edit.text.length) - val previous = value.filter { it.isDigit() } - val injectedBefore = (display.count { it.isDigit() } - previous.length).coerceAtLeast(0) - val typed = edit.text.filter { it.isDigit() } - val caretDigits = (digitsBeforeCaret(edit.text, editCaret) - injectedBefore) - .coerceIn(0, (typed.length - injectedBefore).coerceAtLeast(0)) - val separatorDeleted = typed == previous && edit.text.length < display.length - val digits = if (separatorDeleted) dropDigitBefore(previous, caretDigits) else typed - val nextValue = CountryManager.phoneValueForTypedDigits(digits, fallback) - val nextDigitIndex = if (separatorDeleted) maxOf(caretDigits - 1, 0) else caretDigits - onValueChange(nextValue) - val storedDigits = nextValue.filter { it.isDigit() } - val nextCountry = CountryManager.countryForTypedPhone(storedDigits, fallback) ?: country - val nextDisplay = CountryManager.formatAsYouType(nextValue, nextCountry) - val injected = (nextDisplay.count { it.isDigit() } - storedDigits.length).coerceAtLeast(0) - caret = caretAfterDigit(nextDisplay, injected + nextDigitIndex) + onValueChange = { input -> + editor = normalizePhoneEdit(field, input, country) + if (editor.text != value) onValueChange(editor.text) }, modifier = modifier .fillMaxWidth() @@ -125,7 +115,7 @@ fun PhoneInput( label = { Text(label) }, placeholder = { Text(example) }, prefix = { - if (display.isBlank()) { + if (value.isEmpty()) { Text( text = "+${country.code}", modifier = Modifier.semantics { contentDescription = dialCodeLabel }, @@ -138,7 +128,7 @@ fun PhoneInput( style = MaterialTheme.typography.titleMedium, modifier = Modifier .testTag(PhoneInputTags.COUNTRY) - .clickable(enabled = enabled) { pickerVisible = true } + .clickable(enabled = enabled, role = Role.Button) { pickerVisible = true } .semantics { contentDescription = countryLabel } .padding(horizontal = 4.dp), ) @@ -150,19 +140,29 @@ fun PhoneInput( keyboardType = KeyboardType.Phone, imeAction = imeAction, ), - keyboardActions = KeyboardActions(onDone = { onImeAction() }), + keyboardActions = KeyboardActions( + onDone = { onImeAction() }, + onNext = { onImeAction() }, + onPrevious = { onImeAction() }, + onGo = { onImeAction() }, + onSearch = { onImeAction() }, + onSend = { onImeAction() }, + ), + visualTransformation = transformation, enabled = enabled, ) - if (pickerVisible) { + if (pickerVisible && enabled) { CountryPicker( - selectedIso = country.iso, + selectedKey = country.pickerKey, onDismiss = { pickerVisible = false }, onSelect = { target -> - pickedIso = target.iso + pickedKey = target.pickerKey pickerVisible = false - onValueChange("+${target.code}") - caret = Int.MAX_VALUE + val national = digits.takeIf { it.startsWith(country.code) }?.removePrefix(country.code).orEmpty() + val nextValue = "+${target.code}$national" + editor = TextFieldValue(nextValue, TextRange(nextValue.length)) + onValueChange(nextValue) }, ) } @@ -170,7 +170,7 @@ fun PhoneInput( @Composable private fun CountryPicker( - selectedIso: String, + selectedKey: String, onDismiss: () -> Unit, onSelect: (Country) -> Unit, ) { @@ -223,7 +223,7 @@ private fun CountryPicker( Text( text = item.name, style = MaterialTheme.typography.bodyLarge, - fontWeight = if (item.iso == selectedIso) { + fontWeight = if (item.pickerKey == selectedKey) { FontWeight.SemiBold } else { FontWeight.Normal @@ -248,24 +248,74 @@ private fun CountryPicker( ) } -internal fun digitsBeforeCaret(text: String, caret: Int): Int { - val clamped = caret.coerceIn(0, text.length) - return text.take(clamped).count { it.isDigit() } +internal fun insertDefaultDialCode( + edit: TextFieldValue, + previousValue: String, + country: Country, +): TextFieldValue { + if (previousValue.any { it.isDigit() } || + edit.text.none { it.isDigit() } || + edit.text.trimStart().startsWith("+") + ) { + return edit + } + + val digits = edit.text.filter(Char::isDigit) + val internationalCountry = CountryManager.countryForPhone(digits) + if (internationalCountry != null && + CountryManager.isValidPhoneNumber("+$digits", internationalCountry.iso) && + (digits.startsWith(country.code) || !CountryManager.isValidPhoneNumber("+${country.code}$digits", country.iso)) + ) { + return edit + } + + val prefix = "+${country.code}" + return edit.copy( + text = prefix + edit.text, + selection = TextRange(edit.selection.start + prefix.length, edit.selection.end + prefix.length), + composition = edit.composition?.let { + TextRange(it.start + prefix.length, it.end + prefix.length) + }, + ) } -internal fun caretAfterDigit(formatted: String, digitIndex: Int): Int { - if (formatted.isEmpty() || digitIndex <= 0) return 0 - var seen = 0 - formatted.forEachIndexed { index, char -> - if (char.isDigit()) { - seen += 1 - if (seen == digitIndex) return index + 1 - } +internal fun normalizePhoneEdit(previous: TextFieldValue, input: TextFieldValue, country: Country): TextFieldValue { + if (input.text == previous.text) return input + val replacingAll = previous.selection.min == 0 && previous.selection.max == previous.text.length + val edit = insertDefaultDialCode(input, if (replacingAll) "" else previous.text, country) + val digits = edit.text.filter(Char::isDigit) + val hasPlus = edit.text.trimStart().startsWith("+") + val text = if (digits.isNotEmpty() || hasPlus) "+$digits" else "" + fun offset(index: Int): Int = when { + index == 0 -> 0 + else -> (1 + edit.text.take(index).count(Char::isDigit)).coerceAtMost(text.length) } - return formatted.length + fun range(range: TextRange) = TextRange(offset(range.start), offset(range.end)) + return TextFieldValue(text, range(edit.selection), edit.composition?.let(::range)) } -internal fun dropDigitBefore(digits: String, digitIndex: Int): String { - if (digitIndex <= 0 || digitIndex > digits.length) return digits - return digits.removeRange(digitIndex - 1, digitIndex) +internal class PhoneNumberTransformation(private val country: Country) : VisualTransformation { + override fun filter(text: AnnotatedString): TransformedText { + val formatted = CountryManager.formatAsYouType(text.text, country) + fun digitOffsets(value: String) = value.mapIndexedNotNull { index, char -> + (index + 1).takeIf { char.isDigit() } + } + val originalDigits = digitOffsets(text.text) + val formattedDigits = digitOffsets(formatted) + fun offset(source: String, target: String, targetDigits: List, index: Int): Int { + if (index == 0) return 0 + val digits = source.take(index).count(Char::isDigit) + return if (digits > 0) targetDigits[digits - 1] else if (target.startsWith("+")) 1 else 0 + } + return TransformedText( + AnnotatedString(formatted), + object : OffsetMapping { + override fun originalToTransformed(offset: Int): Int = + offset(text.text, formatted, formattedDigits, offset) + + override fun transformedToOriginal(offset: Int): Int = + offset(formatted, text.text, originalDigits, offset) + }, + ) + } } diff --git a/feature/auth/src/test/java/org/monogram/feature/auth/ui/PhoneInputTest.kt b/feature/auth/src/test/java/org/monogram/feature/auth/ui/PhoneInputTest.kt new file mode 100644 index 000000000..96a2b769c --- /dev/null +++ b/feature/auth/src/test/java/org/monogram/feature/auth/ui/PhoneInputTest.kt @@ -0,0 +1,156 @@ +package org.monogram.feature.auth.ui + +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.input.TextFieldValue +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import org.monogram.core.common.Country +import org.monogram.core.common.CountryManager + +class PhoneInputTest { + private val country = Country(name = "Belarus", code = "375", iso = "BY", flagEmoji = "πŸ‡§πŸ‡Ύ") + + @Test + fun firstDigitIncludesDisplayedCountryCode() { + val edit = insertDefaultDialCode(TextFieldValue("2", TextRange(1)), "", country) + + assertEquals("+3752", edit.text) + assertEquals(TextRange(5), edit.selection) + } + + @Test + fun firstDigitMatchingCountryCodeStillBelongsToNationalNumber() { + val country = Country(name = "United States", code = "1", iso = "US", flagEmoji = "πŸ‡ΊπŸ‡Έ") + val edit = insertDefaultDialCode(TextFieldValue("1", TextRange(1)), "", country) + + assertEquals("+11", edit.text) + assertEquals(TextRange(3), edit.selection) + } + + @Test + fun nationalNumberPasteIncludesDisplayedCountryCode() { + val edit = insertDefaultDialCode(TextFieldValue("29 123 45 67", TextRange(12)), "", country) + + assertEquals("+37529 123 45 67", edit.text) + assertEquals(TextRange(16), edit.selection) + } + + @Test + fun explicitInternationalNumberKeepsItsCountryCode() { + val input = TextFieldValue("+49 151 1234567", TextRange(14)) + + assertEquals(input, insertDefaultDialCode(input, "", country)) + } + + @Test + fun laterEditsDoNotInsertCountryCodeAgain() { + val input = TextFieldValue("+37529", TextRange(6)) + + assertEquals(input, insertDefaultDialCode(input, "+3752", country)) + } + + @Test + fun clearingInputLeavesItEmpty() { + val input = TextFieldValue("") + + assertEquals(input, insertDefaultDialCode(input, "+3752", country)) + assertEquals(input, insertDefaultDialCode(input, "", country)) + } + + @Test + fun selectionOnlyEditPreservesTheEntireRange() { + val previous = TextFieldValue("+375291234567", TextRange(12)) + val input = previous.copy(selection = TextRange(9, 4)) + + assertEquals(input, normalizePhoneEdit(previous, input, country)) + } + + @Test + fun internationalPasteRemovesSeparatorsAndMapsSelection() { + val input = TextFieldValue("+1 (202) 555-0123", TextRange(17)) + val edit = normalizePhoneEdit(TextFieldValue(""), input, country) + + assertEquals("+12025550123", edit.text) + assertEquals(TextRange(12), edit.selection) + } + + @Test + fun replacingAllWithANationalNumberIncludesCountryCode() { + val previous = TextFieldValue("+375291234567", TextRange(0, 13)) + val input = TextFieldValue("291234567", TextRange(9)) + val edit = normalizePhoneEdit(previous, input, country) + + assertEquals("+375291234567", edit.text) + assertEquals(TextRange(13), edit.selection) + } + + @Test + fun completeNumberPastedWithoutPlusDoesNotRepeatCountryCode() { + val input = TextFieldValue("375291234567", TextRange(12)) + val edit = normalizePhoneEdit(TextFieldValue(""), input, country) + + assertEquals("+375291234567", edit.text) + assertEquals(TextRange(13), edit.selection) + } + + @Test + fun internationalPasteWithoutPlusCanOverrideDefaultCountry() { + val input = TextFieldValue("1 202 555 0123", TextRange(14)) + val edit = normalizePhoneEdit(TextFieldValue(""), input, country) + + assertEquals("+12025550123", edit.text) + assertEquals(TextRange(12), edit.selection) + } + + @Test + fun validNationalPasteTakesPriorityOverAnOverlappingInternationalCode() { + val country = checkNotNull(CountryManager.countryForIso("US")) + val input = TextFieldValue("2025550123", TextRange(10)) + val edit = normalizePhoneEdit(TextFieldValue(""), input, country) + + assertEquals("+12025550123", edit.text) + assertEquals(TextRange(12), edit.selection) + } + + @Test + fun compositionRangeTracksInsertedCountryCode() { + val input = TextFieldValue("29", TextRange(2), TextRange(0, 2)) + val edit = normalizePhoneEdit(TextFieldValue(""), input, country) + + assertEquals("+37529", edit.text) + assertEquals(TextRange(6), edit.selection) + assertEquals(TextRange(4, 6), edit.composition) + } + + @Test + fun deletingBackToPlusDoesNotRestoreCountryCode() { + val edit = normalizePhoneEdit(TextFieldValue("+3", TextRange(2)), TextFieldValue("+", TextRange(1)), country) + + assertEquals("+", edit.text) + assertEquals(TextRange(1), edit.selection) + } + + @Test + fun formattingMapsEveryCursorPositionAndPreservesDigitsForEveryCountry() { + CountryManager.countries().forEach { country -> + val transformation = PhoneNumberTransformation(country) + val samples = listOf("", "+", "+${country.code}") + + listOf("2", "202", "2025550123", "1234567890123456").map { "+${country.code}$it" } + samples.forEach { phone -> + val transformed = transformation.filter(AnnotatedString(phone)) + val formatted = transformed.text.text + assertEquals(country.pickerKey, phone.filter(Char::isDigit), formatted.filter(Char::isDigit)) + (0..phone.length).forEach { index -> + val offset = transformed.offsetMapping.originalToTransformed(index) + assertTrue(country.pickerKey, offset in 0..formatted.length) + assertEquals(country.pickerKey, index, transformed.offsetMapping.transformedToOriginal(offset)) + } + (0..formatted.length).forEach { index -> + assertTrue(country.pickerKey, transformed.offsetMapping.transformedToOriginal(index) in 0..phone.length) + } + } + } + } +}