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
17 changes: 0 additions & 17 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,23 +5,6 @@ updates:
schedule:
interval: weekly
open-pull-requests-limit: 10
groups:
# Both libphonenumber artifacts must move together — see the note in gradle/libs.versions.toml.
# Grouping them into one PR makes any version skew visible in a single diff rather than in two
# PRs that can land independently.
libphonenumber:
patterns:
- "*libphonenumber*"
ignore:
# Google's libphonenumber can only move when the Android port (io.michaelrocks) has a matching
# release — the two ship separate copies of the metadata and must stay on the same version, or
# they can disagree on whether a number is valid. See the note in gradle/libs.versions.toml.
# The port has no 9.0.37, so this specific version is held. Grouping alone does not prevent it:
# a group PR still gets opened with just the one artifact when only that artifact has an update,
# and closing such a PR does NOT suppress the version (Dependabot says so explicitly on close).
# Remove this entry once the port publishes 9.0.37 or later.
- dependency-name: "com.googlecode.libphonenumber:libphonenumber"
versions: ["9.0.37"]
labels:
- "dependencies"
- "type: build"
Expand Down
2 changes: 1 addition & 1 deletion apps/flipcash/shared/onramp/coinbase/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ dependencies {
implementation(libs.androidx.localbroadcastmanager)
implementation(libs.bundles.kotlinx.serialization)

implementation(libs.lib.phone.number.google)
implementation(libs.lib.phone.number.port)
implementation(libs.play.services.wallet)
implementation(libs.kotlinx.coroutines.play.services)
implementation(project(":libs:messaging"))
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.flipcash.app.onramp

import com.google.i18n.phonenumbers.PhoneNumberUtil
import com.google.i18n.phonenumbers.NumberParseException
import android.content.Context
import dagger.hilt.android.qualifiers.ApplicationContext
import io.michaelrocks.libphonenumber.android.NumberParseException
import io.michaelrocks.libphonenumber.android.PhoneNumberUtil
import javax.inject.Inject
import javax.inject.Singleton

/**
* Region derived from a phone number for Coinbase buy-options lookups.
Expand All @@ -22,33 +26,48 @@ data class PhoneRegion(
*/
private val NYC_AREA_CODES = setOf("212", "718", "917", "646", "347", "929", "586")

private val phoneUtil: PhoneNumberUtil = PhoneNumberUtil.getInstance()

/**
* Extracts the [PhoneRegion] from an E.164 phone number using libphonenumber
* for country detection and area code heuristics for US subdivision.
* Resolves the [PhoneRegion] for a phone number.
*
* @return the detected region, or null if the number can't be parsed.
* Injectable rather than a top-level function because the Android libphonenumber port loads its
* metadata from the AAR's assets, so it needs a [Context] — unlike Google's desktop artifact, which
* exposes a static instance. See the note in `gradle/libs.versions.toml` for why the port is the
* only libphonenumber this app depends on.
*/
fun regionFromPhone(phone: String): PhoneRegion? {
val parsed = try {
phoneUtil.parse(phone, null)
} catch (_: NumberParseException) {
return null
}
@Singleton
class PhoneRegionResolver @Inject constructor(
@ApplicationContext private val context: Context,
) {
// Lazy so libphonenumber's metadata blob is parsed on first *use* (a background caller), never
// during construction on the main thread. Same reasoning as PhoneUtils in :shared:phone.
private val phoneUtil by lazy { PhoneNumberUtil.createInstance(context) }

val country = phoneUtil.getRegionCodeForNumber(parsed) ?: return null
val subdivision = if (country == "US") {
subdivisionFromUsNumber(parsed.nationalNumber.toString())
} else {
null
}
/**
* Extracts the [PhoneRegion] from an E.164 phone number using libphonenumber
* for country detection and area code heuristics for US subdivision.
*
* @return the detected region, or null if the number can't be parsed.
*/
fun regionFromPhone(phone: String): PhoneRegion? {
val parsed = try {
phoneUtil.parse(phone, null)
} catch (_: NumberParseException) {
return null
}

return PhoneRegion(country = country, subdivision = subdivision)
}
val country = phoneUtil.getRegionCodeForNumber(parsed) ?: return null
val subdivision = if (country == "US") {
subdivisionFromUsNumber(parsed.nationalNumber.toString())
} else {
null
}

return PhoneRegion(country = country, subdivision = subdivision)
}

private fun subdivisionFromUsNumber(nationalNumber: String): String? {
if (nationalNumber.length < 3) return null
val areaCode = nationalNumber.take(3)
return if (areaCode in NYC_AREA_CODES) "NY" else null
private fun subdivisionFromUsNumber(nationalNumber: String): String? {
if (nationalNumber.length < 3) return null
val areaCode = nationalNumber.take(3)
return if (areaCode in NYC_AREA_CODES) "NY" else null
}
}
Original file line number Diff line number Diff line change
@@ -1,41 +1,58 @@
package com.flipcash.app.onramp

import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNull

// Robolectric, because the Android libphonenumber port loads its metadata from the AAR's assets and
// so needs a real Context. These cases are unchanged from when this resolved via Google's desktop
// artifact — they are the parity check that the port agrees with it on every number here.
@RunWith(RobolectricTestRunner::class)
@Config(manifest = Config.NONE)
class PhoneRegionTest {

private lateinit var resolver: PhoneRegionResolver

@BeforeTest
fun setup() {
resolver = PhoneRegionResolver(RuntimeEnvironment.getApplication())
}

// region NYC area codes

@Test
fun `212 area code returns US-NY`() {
assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+12125551234"))
assertEquals(PhoneRegion("US", "NY"), resolver.regionFromPhone("+12125551234"))
}

@Test
fun `718 area code returns US-NY`() {
assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+17185551234"))
assertEquals(PhoneRegion("US", "NY"), resolver.regionFromPhone("+17185551234"))
}

@Test
fun `917 area code returns US-NY`() {
assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+19175551234"))
assertEquals(PhoneRegion("US", "NY"), resolver.regionFromPhone("+19175551234"))
}

@Test
fun `646 area code returns US-NY`() {
assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+16465551234"))
assertEquals(PhoneRegion("US", "NY"), resolver.regionFromPhone("+16465551234"))
}

@Test
fun `347 area code returns US-NY`() {
assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+13475551234"))
assertEquals(PhoneRegion("US", "NY"), resolver.regionFromPhone("+13475551234"))
}

@Test
fun `929 area code returns US-NY`() {
assertEquals(PhoneRegion("US", "NY"), regionFromPhone("+19295551234"))
assertEquals(PhoneRegion("US", "NY"), resolver.regionFromPhone("+19295551234"))
}

// endregion
Expand All @@ -44,7 +61,7 @@ class PhoneRegionTest {

@Test
fun `non-NYC US area code returns US with no subdivision`() {
assertEquals(PhoneRegion("US", null), regionFromPhone("+14155551234")) // SF
assertEquals(PhoneRegion("US", null), resolver.regionFromPhone("+14155551234")) // SF
}

// endregion
Expand All @@ -53,17 +70,17 @@ class PhoneRegionTest {

@Test
fun `Canadian number returns CA`() {
assertEquals(PhoneRegion("CA", null), regionFromPhone("+14165551234")) // Toronto
assertEquals(PhoneRegion("CA", null), resolver.regionFromPhone("+14165551234")) // Toronto
}

@Test
fun `UK number returns GB`() {
assertEquals(PhoneRegion("GB", null), regionFromPhone("+442071234567"))
assertEquals(PhoneRegion("GB", null), resolver.regionFromPhone("+442071234567"))
}

@Test
fun `German number returns DE`() {
assertEquals(PhoneRegion("DE", null), regionFromPhone("+4915112345678"))
assertEquals(PhoneRegion("DE", null), resolver.regionFromPhone("+4915112345678"))
}

// endregion
Expand All @@ -72,17 +89,17 @@ class PhoneRegionTest {

@Test
fun `invalid number returns null`() {
assertNull(regionFromPhone("12345"))
assertNull(resolver.regionFromPhone("12345"))
}

@Test
fun `empty string returns null`() {
assertNull(regionFromPhone(""))
assertNull(resolver.regionFromPhone(""))
}

@Test
fun `non-numeric string returns null`() {
assertNull(regionFromPhone("not-a-phone"))
assertNull(resolver.regionFromPhone("not-a-phone"))
}

// endregion
Expand Down
13 changes: 6 additions & 7 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -64,13 +64,13 @@ protobuf = "4.35.1"
protobuf-plugin = "0.10.0"
protovalidate-kt = "0.1.1"

# These two must stay on the SAME version. `:shared:phone` parses and formats with the Android port
# while the services modules validate with Google's artifact, and each ships its own copy of the
# libphonenumber metadata — so a version skew means the two can disagree on whether a number is
# valid. The port lags upstream, so it sets the ceiling: only bump `-google` once the port has
# published a matching release.
# The Android port is the ONLY libphonenumber this app depends on, deliberately. Google's
# `com.googlecode` artifact used to sit alongside it; the two ship separate copies of the metadata,
# so any version skew between them meant they could disagree on whether a number is valid — and the
# port lags upstream, so it always set the ceiling and blocked routine bumps.
# Don't reintroduce the Google artifact. The port exposes the same API; it just needs a Context,
# because it loads its metadata from the AAR's assets rather than from a static instance.
lib-phone-number-port = "9.0.36"
lib-phone-number-google = "9.0.36"
zxing = "3.5.4"

androidx-benchmark-macro = "1.5.0-rc01"
Expand Down Expand Up @@ -251,7 +251,6 @@ vico-compose = { module = "com.patrykandpatrick.vico:compose", version.ref = "vi

# Phone number
lib-phone-number-port = { module = "io.michaelrocks:libphonenumber-android", version.ref = "lib-phone-number-port" }
lib-phone-number-google = { module = "com.googlecode.libphonenumber:libphonenumber", version.ref = "lib-phone-number-google" }

# QR / Scanning
zxing = { module = "com.google.zxing:core", version.ref = "zxing" }
Expand Down
2 changes: 0 additions & 2 deletions services/flipcash-compose/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,6 @@ dependencies {
ksp(libs.androidx.room.compiler)
implementation(libs.fingerprint.pro)

implementation(libs.lib.phone.number.google)

androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.runner)
Expand Down
2 changes: 0 additions & 2 deletions services/flipcash/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,6 @@ dependencies {

implementation(libs.fingerprint.pro)

implementation(libs.lib.phone.number.google)

androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.runner)
Expand Down
2 changes: 0 additions & 2 deletions services/opencode/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,8 +76,6 @@ dependencies {

implementation(libs.fingerprint.pro)

implementation(libs.lib.phone.number.google)

androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.junit)
androidTestImplementation(libs.androidx.test.runner)
Expand Down
Loading