Skip to content

Commit 18ba968

Browse files
committed
fix(contacts): centralize e164 normalization via PhoneUtils.toE164
The inline normalizeToE164 in FullAccessContactReader and PickerContactReader used a fragile cleanNumber pipeline that could produce non-E.164 strings for certain country formats. Replace both with a single PhoneUtils.toE164 that delegates to libphonenumber parse/isValidNumber/format(E164) pipeline, which is authoritative.
1 parent 9c1fbc4 commit 18ba968

4 files changed

Lines changed: 90 additions & 28 deletions

File tree

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/FullAccessContactReader.kt

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -64,18 +64,5 @@ class FullAccessContactReader @Inject constructor(
6464
return Result.success(result)
6565
}
6666

67-
private fun normalizeToE164(rawNumber: String): String? {
68-
val cleaned = rawNumber.filter { it.isDigit() || it == '+' }
69-
if (cleaned.isBlank()) return null
70-
71-
val withPlus = if (cleaned.startsWith("+")) cleaned else "+$cleaned"
72-
if (!phoneUtils.isPhoneNumberValid(withPlus)) return null
73-
74-
val countryCode = phoneUtils.getCountryCode(withPlus.removePrefix("+"))
75-
val locale = phoneUtils.countryLocales.find { it.countryCode == countryCode }
76-
?: phoneUtils.defaultCountryLocale
77-
78-
val result = phoneUtils.cleanNumber(withPlus.removePrefix("+${locale.phoneCode}"), locale)
79-
return result.ifBlank { null }
80-
}
67+
private fun normalizeToE164(rawNumber: String): String? = phoneUtils.toE164(rawNumber)
8168
}

apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/device/internal/PickerContactReader.kt

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,18 +51,5 @@ class PickerContactReader @Inject constructor(
5151
return Result.success(result)
5252
}
5353

54-
private fun normalizeToE164(rawNumber: String): String? {
55-
val cleaned = rawNumber.filter { it.isDigit() || it == '+' }
56-
if (cleaned.isBlank()) return null
57-
58-
val withPlus = if (cleaned.startsWith("+")) cleaned else "+$cleaned"
59-
if (!phoneUtils.isPhoneNumberValid(withPlus)) return null
60-
61-
val countryCode = phoneUtils.getCountryCode(withPlus.removePrefix("+"))
62-
val locale = phoneUtils.countryLocales.find { it.countryCode == countryCode }
63-
?: phoneUtils.defaultCountryLocale
64-
65-
val result = phoneUtils.cleanNumber(withPlus.removePrefix("+${locale.phoneCode}"), locale)
66-
return result.ifBlank { null }
67-
}
54+
private fun normalizeToE164(rawNumber: String): String? = phoneUtils.toE164(rawNumber)
6855
}

apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/PhoneUtils.kt

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,21 @@ class PhoneUtils @Inject constructor(
129129
}
130130
}
131131

132+
fun toE164(rawNumber: String): String? {
133+
val cleaned = rawNumber.filter { it.isDigit() || it == '+' }
134+
if (cleaned.isBlank()) return null
135+
136+
return try {
137+
val parsed = phoneNumberUtil.parse(cleaned, defaultCountryLocale.countryCode)
138+
if (!phoneNumberUtil.isValidNumber(parsed)) return null
139+
val type = phoneNumberUtil.getNumberType(parsed)
140+
if (type == PhoneNumberUtil.PhoneNumberType.UNKNOWN) return null
141+
phoneNumberUtil.format(parsed, PhoneNumberUtil.PhoneNumberFormat.E164)
142+
} catch (_: NumberParseException) {
143+
null
144+
}
145+
}
146+
132147
fun toFlagEmoji(country: String): String {
133148
// 1. It first checks if the string consists of only 2 characters: ISO 3166-1 alpha-2 two-letter country codes (https://en.wikipedia.org/wiki/Regional_Indicator_Symbol).
134149
if (country.length != 2) {

apps/flipcash/shared/phone/src/test/kotlin/com/flipcash/app/phone/PhoneUtilsTest.kt

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import org.robolectric.RuntimeEnvironment
1515
import org.robolectric.annotation.Config
1616
import kotlin.test.assertEquals
1717
import kotlin.test.assertFalse
18+
import kotlin.test.assertNull
1819
import kotlin.test.assertTrue
1920

2021
@RunWith(RobolectricTestRunner::class)
@@ -138,6 +139,78 @@ class PhoneUtilsTest {
138139

139140
// endregion
140141

142+
// region toE164
143+
144+
@Test
145+
fun `toE164 normalizes international number with plus`() {
146+
val mockNumber = mockk<io.michaelrocks.libphonenumber.android.Phonenumber.PhoneNumber>(relaxed = true)
147+
every { mockPhoneNumberUtil.parse("+12025551234", "US") } returns mockNumber
148+
every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns true
149+
every { mockPhoneNumberUtil.getNumberType(mockNumber) } returns PhoneNumberUtil.PhoneNumberType.FIXED_LINE_OR_MOBILE
150+
every { mockPhoneNumberUtil.format(mockNumber, PhoneNumberUtil.PhoneNumberFormat.E164) } returns "+12025551234"
151+
152+
assertEquals("+12025551234", phoneUtils.toE164("+12025551234"))
153+
}
154+
155+
@Test
156+
fun `toE164 normalizes national number without country code`() {
157+
val mockNumber = mockk<io.michaelrocks.libphonenumber.android.Phonenumber.PhoneNumber>(relaxed = true)
158+
every { mockPhoneNumberUtil.parse("2025551234", "US") } returns mockNumber
159+
every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns true
160+
every { mockPhoneNumberUtil.getNumberType(mockNumber) } returns PhoneNumberUtil.PhoneNumberType.FIXED_LINE_OR_MOBILE
161+
every { mockPhoneNumberUtil.format(mockNumber, PhoneNumberUtil.PhoneNumberFormat.E164) } returns "+12025551234"
162+
163+
assertEquals("+12025551234", phoneUtils.toE164("2025551234"))
164+
}
165+
166+
@Test
167+
fun `toE164 strips formatting characters`() {
168+
val mockNumber = mockk<io.michaelrocks.libphonenumber.android.Phonenumber.PhoneNumber>(relaxed = true)
169+
every { mockPhoneNumberUtil.parse("+12025551234", "US") } returns mockNumber
170+
every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns true
171+
every { mockPhoneNumberUtil.getNumberType(mockNumber) } returns PhoneNumberUtil.PhoneNumberType.FIXED_LINE_OR_MOBILE
172+
every { mockPhoneNumberUtil.format(mockNumber, PhoneNumberUtil.PhoneNumberFormat.E164) } returns "+12025551234"
173+
174+
assertEquals("+12025551234", phoneUtils.toE164("+1 (202) 555-1234"))
175+
}
176+
177+
@Test
178+
fun `toE164 returns null for blank input`() {
179+
assertNull(phoneUtils.toE164(""))
180+
assertNull(phoneUtils.toE164(" "))
181+
}
182+
183+
@Test
184+
fun `toE164 returns null for invalid number`() {
185+
val mockNumber = mockk<io.michaelrocks.libphonenumber.android.Phonenumber.PhoneNumber>(relaxed = true)
186+
every { mockPhoneNumberUtil.parse("123", "US") } returns mockNumber
187+
every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns false
188+
189+
assertNull(phoneUtils.toE164("123"))
190+
}
191+
192+
@Test
193+
fun `toE164 returns null for unparseable number`() {
194+
every { mockPhoneNumberUtil.parse("abc", "US") } throws io.michaelrocks.libphonenumber.android.NumberParseException(
195+
io.michaelrocks.libphonenumber.android.NumberParseException.ErrorType.NOT_A_NUMBER,
196+
"not a number"
197+
)
198+
199+
assertNull(phoneUtils.toE164("abc"))
200+
}
201+
202+
@Test
203+
fun `toE164 returns null for UNKNOWN number type`() {
204+
val mockNumber = mockk<io.michaelrocks.libphonenumber.android.Phonenumber.PhoneNumber>(relaxed = true)
205+
every { mockPhoneNumberUtil.parse("5551234567", "US") } returns mockNumber
206+
every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns true
207+
every { mockPhoneNumberUtil.getNumberType(mockNumber) } returns PhoneNumberUtil.PhoneNumberType.UNKNOWN
208+
209+
assertNull(phoneUtils.toE164("5551234567"))
210+
}
211+
212+
// endregion
213+
141214
// region formatNumber
142215

143216
@Test

0 commit comments

Comments
 (0)