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