Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 156 additions & 0 deletions app/src/androidTest/java/org/monogram/PhoneInputEditingTest.kt
Original file line number Diff line number Diff line change
@@ -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<ComponentActivity>()

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++ },
)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) }
Expand Down Expand Up @@ -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? {
Expand All @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
}
}
Loading