From 7e88ab43f2b02eed40d90ee2d121176e6a09d014 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 11 Jul 2026 10:30:36 -0400 Subject: [PATCH 1/4] perf(phone): defer PhoneUtils libphonenumber init off the constructor --- .../com/flipcash/app/phone/PhoneUtils.kt | 104 ++++++++++++------ .../com/flipcash/app/phone/PhoneUtilsTest.kt | 49 ++++++++- 2 files changed, 114 insertions(+), 39 deletions(-) diff --git a/apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/PhoneUtils.kt b/apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/PhoneUtils.kt index c23fd4e05..b7f0d13a8 100644 --- a/apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/PhoneUtils.kt +++ b/apps/flipcash/shared/phone/src/main/kotlin/com/flipcash/app/phone/PhoneUtils.kt @@ -10,48 +10,74 @@ import io.michaelrocks.libphonenumber.android.PhoneNumberUtil import java.util.Locale import javax.inject.Inject import javax.inject.Singleton +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock @Singleton class PhoneUtils @Inject constructor( @ApplicationContext private val context: Context, - exchange: Exchange, + private val exchange: Exchange, ) { - var countryLocales: List = listOf() - private var countryCodesMap: Map = mapOf() - var defaultCountryLocale: CountryLocale - - private val phoneNumberUtil = PhoneNumberUtil.createInstance(context) - - init { - countryLocales = phoneNumberUtil.supportedRegions.map { region -> - val countryCode = phoneNumberUtil.getCountryCodeForRegion(region) - val resId: Int? = exchange.getFlag(region) - val displayCountry = Locale(Locale.getDefault().language, region).displayCountry - - CountryLocale( - name = displayCountry, - phoneCode = countryCode, - countryCode = region, - resId = resId - ) - } - .sortedBy { it.name } - .filter { it.resId != null } - - countryCodesMap = countryLocales.map { it }.associateBy { it.phoneCode } - val isoCountry = Locale.getDefault().country - defaultCountryLocale = - countryLocales.find { it.countryCode == isoCountry } - ?: countryLocales.firstOrNull() - ?: CountryLocale( - name = Locale(Locale.getDefault().language, isoCountry).displayCountry, - phoneCode = phoneNumberUtil.getCountryCodeForRegion(isoCountry), - countryCode = isoCountry, - resId = null + @Volatile + var countryLocales: List = emptyList() + + @Volatile + private var countryCodesMap: Map = emptyMap() + + @Volatile + var defaultCountryLocale: CountryLocale = CountryLocale.Stub + + // Lazy so libphonenumber's metadata blob is parsed on first *use* (a + // background caller), never during construction on the main thread. + private val phoneNumberUtil by lazy { PhoneNumberUtil.createInstance(context) } + + private val loadMutex = Mutex() + + @Volatile + private var loaded = false + + /** + * Loads libphonenumber metadata and builds the sorted country list. Heavy + * (metadata parse + ~250-region enumeration; hundreds of ms on low-end + * devices), so it MUST be called from a background dispatcher — never during + * construction or on the main thread. Idempotent and safe to call from + * multiple sites. + */ + suspend fun ensureLoaded() { + if (loaded) return + loadMutex.withLock { + if (loaded) return + val locales = phoneNumberUtil.supportedRegions.map { region -> + val countryCode = phoneNumberUtil.getCountryCodeForRegion(region) + val resId: Int? = exchange.getFlag(region) + val displayCountry = Locale(Locale.getDefault().language, region).displayCountry + + CountryLocale( + name = displayCountry, + phoneCode = countryCode, + countryCode = region, + resId = resId, ) + } + .sortedBy { it.name } + .filter { it.resId != null } + + countryLocales = locales + countryCodesMap = locales.associateBy { it.phoneCode } + val isoCountry = Locale.getDefault().country + defaultCountryLocale = + locales.find { it.countryCode == isoCountry } + ?: locales.firstOrNull() + ?: CountryLocale( + name = Locale(Locale.getDefault().language, isoCountry).displayCountry, + phoneCode = phoneNumberUtil.getCountryCodeForRegion(isoCountry), + countryCode = isoCountry, + resId = null, + ) + loaded = true + } } - fun getCountryCode(number: String): String { val map = countryCodesMap for (k in map.keys) { @@ -138,6 +164,9 @@ class PhoneUtils @Inject constructor( /** * Parse an international phone number (e.g. "+15551234567") into its country locale * and national number digits. Returns null if parsing fails. + * + * Requires [ensureLoaded] to have completed before calling — parsing uses + * [defaultCountryLocale] as the hint region, which is empty until loaded. */ fun parseInternationalNumber(number: String): Pair? { val cleaned = number.filter { it.isDigit() || it == '+' } @@ -156,6 +185,13 @@ class PhoneUtils @Inject constructor( } } + /** + * Converts a raw phone number string to E.164 format. Returns null if the + * number is blank, unparseable, invalid, or of UNKNOWN type. + * + * Requires [ensureLoaded] to have completed before calling — parsing uses + * [defaultCountryLocale] as the hint region, which is empty until loaded. + */ fun toE164(rawNumber: String): String? { val cleaned = rawNumber.filter { it.isDigit() || it == '+' } if (cleaned.isBlank()) return null diff --git a/apps/flipcash/shared/phone/src/test/kotlin/com/flipcash/app/phone/PhoneUtilsTest.kt b/apps/flipcash/shared/phone/src/test/kotlin/com/flipcash/app/phone/PhoneUtilsTest.kt index a6a17a749..ac43e2778 100644 --- a/apps/flipcash/shared/phone/src/test/kotlin/com/flipcash/app/phone/PhoneUtilsTest.kt +++ b/apps/flipcash/shared/phone/src/test/kotlin/com/flipcash/app/phone/PhoneUtilsTest.kt @@ -10,6 +10,7 @@ import io.mockk.mockkStatic import io.mockk.unmockkObject import io.mockk.unmockkStatic import io.mockk.verify +import kotlinx.coroutines.runBlocking import org.junit.After import org.junit.Before import org.junit.Test @@ -126,27 +127,63 @@ class PhoneUtilsTest { // region getCountryCode @Test - fun `getCountryCode detects US from prefix 1`() { + fun `getCountryCode detects US from prefix 1`() = runBlocking { + phoneUtils.ensureLoaded() assertEquals("US", phoneUtils.getCountryCode("12025551234")) } @Test - fun `getCountryCode detects UK from prefix 44`() { + fun `getCountryCode detects UK from prefix 44`() = runBlocking { + phoneUtils.ensureLoaded() assertEquals("GB", phoneUtils.getCountryCode("447911123456")) } @Test fun `getCountryCode returns default for unknown prefix`() { + // Intentionally no ensureLoaded() — exercises the empty-map/default path. val result = phoneUtils.getCountryCode("99999999999") assertTrue(result.isNotEmpty()) } // endregion + // region ensureLoaded / lazy construction + + @Test + fun `construction does not touch libphonenumber metadata`() { + // phoneUtils was constructed in setUp(); no load has run yet. + verify(exactly = 0) { mockPhoneNumberUtil.supportedRegions } + assertTrue(phoneUtils.countryLocales.isEmpty()) + assertEquals(CountryLocale.Stub, phoneUtils.defaultCountryLocale) + } + + @Test + fun `ensureLoaded populates countryLocales and default`() = runBlocking { + phoneUtils.ensureLoaded() + + // setUp() stubs 4 regions (US, GB, CA, DE) and getFlag returns 0 (non-null), + // so all survive the resId != null filter. + assertEquals(4, phoneUtils.countryLocales.size) + assertTrue(phoneUtils.defaultCountryLocale != CountryLocale.Stub) + } + + @Test + fun `ensureLoaded is idempotent`() = runBlocking { + phoneUtils.ensureLoaded() + phoneUtils.ensureLoaded() + + // Region enumeration must run exactly once despite two calls. + // The count of 1 assumes setUp() does NOT call ensureLoaded(). + verify(exactly = 1) { mockPhoneNumberUtil.supportedRegions } + } + + // endregion + // region toE164 @Test - fun `toE164 normalizes international number with plus`() { + fun `toE164 normalizes international number with plus`() = runBlocking { + phoneUtils.ensureLoaded() val mockNumber = mockk(relaxed = true) every { mockPhoneNumberUtil.parse("+12025551234", "US") } returns mockNumber every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns true @@ -157,7 +194,8 @@ class PhoneUtilsTest { } @Test - fun `toE164 normalizes national number without country code`() { + fun `toE164 normalizes national number without country code`() = runBlocking { + phoneUtils.ensureLoaded() val mockNumber = mockk(relaxed = true) every { mockPhoneNumberUtil.parse("2025551234", "US") } returns mockNumber every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns true @@ -168,7 +206,8 @@ class PhoneUtilsTest { } @Test - fun `toE164 strips formatting characters`() { + fun `toE164 strips formatting characters`() = runBlocking { + phoneUtils.ensureLoaded() val mockNumber = mockk(relaxed = true) every { mockPhoneNumberUtil.parse("+12025551234", "US") } returns mockNumber every { mockPhoneNumberUtil.isValidNumber(mockNumber) } returns true From 90a93a6f63577bbc70b4fc6fb1e2c39bd5b4cf7b Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 11 Jul 2026 10:39:09 -0400 Subject: [PATCH 2/4] perf(phone): pre-warm PhoneUtils off the main thread at startup --- .../app/src/main/kotlin/com/flipcash/app/MainActivity.kt | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt index da568d77b..61bf63f79 100644 --- a/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt +++ b/apps/flipcash/app/src/main/kotlin/com/flipcash/app/MainActivity.kt @@ -9,6 +9,9 @@ import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.CompositionLocalProvider import androidx.fragment.app.FragmentActivity +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import com.flipcash.app.analytics.FlipcashAnalyticsService import com.flipcash.app.android.BuildConfig import com.flipcash.app.appsettings.AppSettingsCoordinator @@ -138,6 +141,11 @@ class MainActivity : FragmentActivity() { handleUncaughtException() enableEdgeToEdge() + // Warm libphonenumber metadata off the main thread so the phone + // verification / country picker screens are instant and never block + // the UI thread building the country list. + lifecycleScope.launch(Dispatchers.Default) { phoneUtils.ensureLoaded() } + setContent { CompositionLocalProvider( LocalResources provides resources, From d8ef02aa026edc86c57b5b6aceb42702482cf129 Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 11 Jul 2026 10:46:28 -0400 Subject: [PATCH 3/4] perf(phone): populate country locale async, gate input on PhoneUtils load Stop PhoneVerificationViewModel from reading phoneUtils.defaultCountryLocale eagerly in initialState (forcing main-thread work before libphonenumber is loaded). initialState now uses CountryLocale.Stub; the init coroutine calls ensureLoaded() on the Default dispatcher inside a try/catch (rethrows CancellationException; logs other failures via trace so input observation still starts even if metadata load fails), then dispatches OnCountrySelected with the real locale. Phone-number input observation is extracted to observePhoneNumberInput() and started only after ensureLoaded() completes, so no parse/format ever runs before the metadata is ready. --- .../phone/PhoneVerificationViewModel.kt | 123 +++++++++++------- .../phone/PhoneVerificationViewModelTest.kt | 83 ++++++++++++ 2 files changed, 157 insertions(+), 49 deletions(-) create mode 100644 apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelTest.kt diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt index f6e6ec303..17a09fbda 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt @@ -8,6 +8,7 @@ import com.flipcash.app.featureflags.FeatureFlag import com.flipcash.app.featureflags.FeatureFlagController import com.flipcash.app.phone.CountryLocale import com.flipcash.app.phone.PhoneUtils +import com.flipcash.app.phone.isStub import com.flipcash.features.contact.verification.R import com.flipcash.libs.coroutines.DispatcherProvider import com.flipcash.services.controllers.ContactVerificationController @@ -30,7 +31,9 @@ import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.util.Timer import javax.inject.Inject import kotlin.concurrent.fixedRateTimer @@ -48,7 +51,7 @@ internal class PhoneVerificationViewModel @Inject constructor( private val resources: ResourceHelper, private val dispatchers: DispatcherProvider, ) : BaseViewModel( - initialState = State(selectedLocale = phoneUtils.defaultCountryLocale), + initialState = State(), updateStateForEvent = updateStateForEvent, defaultDispatcher = dispatchers.Default, ) { @@ -105,54 +108,25 @@ internal class PhoneVerificationViewModel @Inject constructor( private var timer: Timer? = null init { - stateFlow - .map { it.numberTextFieldState } - .flatMapLatest { ts -> snapshotFlow { ts.text } } - .distinctUntilChanged() - .onEach { enteredNumber -> - val raw = enteredNumber.toString() - - // Handle autofilled international numbers (e.g. "+15551234567") - if (raw.contains("+")) { - val result = phoneUtils.parseInternationalNumber(raw) - if (result != null) { - val (locale, nationalNumber) = result - dispatchEvent(Event.OnCountrySelected(locale)) - stateFlow.value.numberTextFieldState.edit { - replace(0, length, nationalNumber) - } - return@onEach - } - } - - val countryCode = stateFlow.value.selectedLocale.phoneCode.toString() - val phoneInputFiltered = raw.replace("+$countryCode", "") - val phoneNumber = "+$countryCode$phoneInputFiltered" - val formattedNumber = phoneUtils.formatNumber( - number = phoneNumber, - countryCode = countryCode, - plus = false - ).replaceFirst(countryCode, "").replaceFirst("+", "").trimStart() - - dispatchEvent( - Event.OnPhoneNumberFormatted( - phoneUtils.formatNumber( - number = formattedNumber, - countryCode = stateFlow.value.selectedLocale.countryCode, - plus = false - ) - ) - ) - - dispatchEvent( - Event.OnCanSendCode( - enabled = phoneUtils.isPhoneNumberValid( - number = enteredNumber.toString(), - countryCode = stateFlow.value.selectedLocale.countryCode - ) - ) - ) - }.launchIn(viewModelScope) + viewModelScope.launch { + // PhoneUtils is normally pre-warmed at app startup, so ensureLoaded() + // is typically a cheap no-op. Input observation is intentionally started + // after it completes so libphonenumber parse/format never runs on the + // main thread before metadata is loaded on a background thread. + try { + withContext(dispatchers.Default) { phoneUtils.ensureLoaded() } + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Metadata load failed; continue with the Stub locale. Input still + // functions (formatting may be degraded until a later load succeeds). + trace(message = "PhoneUtils.ensureLoaded failed: $e", type = TraceType.Error) + } + if (stateFlow.value.selectedLocale.isStub()) { + dispatchEvent(Event.OnCountrySelected(phoneUtils.defaultCountryLocale)) + } + observePhoneNumberInput() + } eventFlow .filterIsInstance() @@ -259,6 +233,57 @@ internal class PhoneVerificationViewModel @Inject constructor( ).launchIn(viewModelScope) } + private fun observePhoneNumberInput() { + stateFlow + .map { it.numberTextFieldState } + .flatMapLatest { ts -> snapshotFlow { ts.text } } + .distinctUntilChanged() + .onEach { enteredNumber -> + val raw = enteredNumber.toString() + + // Handle autofilled international numbers (e.g. "+15551234567") + if (raw.contains("+")) { + val result = phoneUtils.parseInternationalNumber(raw) + if (result != null) { + val (locale, nationalNumber) = result + dispatchEvent(Event.OnCountrySelected(locale)) + stateFlow.value.numberTextFieldState.edit { + replace(0, length, nationalNumber) + } + return@onEach + } + } + + val countryCode = stateFlow.value.selectedLocale.phoneCode.toString() + val phoneInputFiltered = raw.replace("+$countryCode", "") + val phoneNumber = "+$countryCode$phoneInputFiltered" + val formattedNumber = phoneUtils.formatNumber( + number = phoneNumber, + countryCode = countryCode, + plus = false + ).replaceFirst(countryCode, "").replaceFirst("+", "").trimStart() + + dispatchEvent( + Event.OnPhoneNumberFormatted( + phoneUtils.formatNumber( + number = formattedNumber, + countryCode = stateFlow.value.selectedLocale.countryCode, + plus = false + ) + ) + ) + + dispatchEvent( + Event.OnCanSendCode( + enabled = phoneUtils.isPhoneNumberValid( + number = enteredNumber.toString(), + countryCode = stateFlow.value.selectedLocale.countryCode + ) + ) + ) + }.launchIn(viewModelScope) + } + private suspend fun handleSendVerificationCode(method: ContactMethod) { dispatchEvent(Event.OnSendingCodeChanged(loading = true)) if (stateFlow.value.attempts >= 3) { diff --git a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelTest.kt b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelTest.kt new file mode 100644 index 000000000..48e695948 --- /dev/null +++ b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelTest.kt @@ -0,0 +1,83 @@ +package com.flipcash.app.contact.verification.internal.phone + +import com.flipcash.app.core.MainCoroutineRule +import com.flipcash.app.core.dispatchers.TestDispatchers +import com.flipcash.app.featureflags.FeatureFlagController +import com.flipcash.app.phone.CountryLocale +import com.flipcash.app.phone.PhoneUtils +import com.flipcash.services.controllers.ContactVerificationController +import com.flipcash.services.controllers.ProfileController +import com.flipcash.services.user.UserManager +import com.getcode.util.resources.FakeResourceHelper +import io.mockk.coEvery +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.UnconfinedTestDispatcher +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import kotlin.test.assertEquals + +@OptIn(ExperimentalCoroutinesApi::class) +class PhoneVerificationViewModelTest { + + @get:Rule + var mainCoroutineRule = MainCoroutineRule(UnconfinedTestDispatcher()) + + private val testLocale = CountryLocale(name = "Nigeria", phoneCode = 234, countryCode = "NG", resId = 1) + + private val phoneUtils: PhoneUtils = mockk(relaxed = true) { + coEvery { ensureLoaded() } returns Unit + every { defaultCountryLocale } returns testLocale + every { formatNumber(any(), any(), any()) } returns "" + every { isPhoneNumberValid(any(), any()) } returns false + every { parseInternationalNumber(any()) } returns null + } + private val verificationController: ContactVerificationController = mockk(relaxed = true) + private val profileController: ProfileController = mockk(relaxed = true) + private val userManager: UserManager = mockk(relaxed = true) + private val featureFlags: FeatureFlagController = mockk(relaxed = true) + private val resources = FakeResourceHelper() + + private fun createViewModel(dispatchers: TestDispatchers) = PhoneVerificationViewModel( + phoneUtils = phoneUtils, + verificationController = verificationController, + profileController = profileController, + userManager = userManager, + featureFlags = featureFlags, + resources = resources, + dispatchers = dispatchers, + ) + + /** + * Verifies that construction does not eagerly read defaultCountryLocale (i.e. initialState + * starts as Stub), and that after ensureLoaded() the state is populated with the real locale. + * + * With the old code (initialState = State(selectedLocale = phoneUtils.defaultCountryLocale)), + * the first assertion (Stub) would fail because defaultCountryLocale is read during construction. + */ + @Test + fun `initial state is Stub before coroutines run`() = runTest(mainCoroutineRule.dispatcher) { + val dispatchers = TestDispatchers(testScheduler) + val viewModel = createViewModel(dispatchers) + + // With OLD code: selectedLocale = testLocale (eager read in initialState) + // With NEW code: selectedLocale = Stub (no eager read; init coroutine hasn't advanced yet) + assertEquals(CountryLocale.Stub, viewModel.stateFlow.value.selectedLocale) + } + + @Test + fun `starts on Stub then populates default locale after ensureLoaded`() = runTest(mainCoroutineRule.dispatcher) { + val dispatchers = TestDispatchers(testScheduler) + val viewModel = createViewModel(dispatchers) + + // Before advancing: construction must not have read defaultCountryLocale eagerly + assertEquals(CountryLocale.Stub, viewModel.stateFlow.value.selectedLocale) + + // After advancing: ensureLoaded completes, OnCountrySelected is dispatched + advanceUntilIdle() + assertEquals(testLocale, viewModel.stateFlow.value.selectedLocale) + } +} From 611e8cb9d86da0839b4327b1b1051096c286516f Mon Sep 17 00:00:00 2001 From: Brandon McAnsh Date: Sat, 11 Jul 2026 11:07:07 -0400 Subject: [PATCH 4/4] fix(phone): surface country locales via ViewModel state for Compose observability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PhoneCountryCodeScreenContent was reading phoneUtils.countryLocales directly from a plain @Volatile field. Compose does not snapshot-track plain field reads, so the picker rendered empty and never recomposed to recover after the async load. Route the list through ViewModel State (countryLocales: List) so it is observed via the existing collectAsStateWithLifecycle() call. The new OnCountryLocalesLoaded event is dispatched in the init coroutine after ensureLoaded() completes, alongside the existing OnCountrySelected dispatch. Also guard the default-locale selection so OnCountrySelected is only dispatched when the loaded default is non-Stub — prevents a +0 / empty-country-code field if ensureLoaded() threw and left defaultCountryLocale == Stub. ContactCoordinator.performSync() gains an ensureLoaded() call before formatting E.164 numbers, guarding against the first sync racing ahead of the app-startup warm-up and using an empty region hint for toE164. --- .../internal/phone/PhoneCountryCodeScreen.kt | 4 +--- .../internal/phone/PhoneVerificationViewModel.kt | 10 ++++++++-- .../phone/PhoneVerificationViewModelTest.kt | 14 ++++++++++++++ .../flipcash/app/contacts/ContactCoordinator.kt | 5 +++++ 4 files changed, 28 insertions(+), 5 deletions(-) diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneCountryCodeScreen.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneCountryCodeScreen.kt index 213601de8..b97622fb2 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneCountryCodeScreen.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneCountryCodeScreen.kt @@ -23,7 +23,6 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import com.flipcash.app.phone.CountryLocale -import com.flipcash.app.phone.LocalPhoneUtils import com.flipcash.app.theme.FlipcashPreview import com.flipcash.shared.phone.R import com.getcode.theme.CodeTheme @@ -42,9 +41,8 @@ private fun PhoneCountryCodeScreenContent( state: PhoneVerificationViewModel.State, dispatchEvent: (PhoneVerificationViewModel.Event) -> Unit, ) { - val phoneUtils = LocalPhoneUtils.current PhoneCountrySelectionList( - availableLocales = phoneUtils.countryLocales, + availableLocales = state.countryLocales, modifier = Modifier.fillMaxSize() ) { dispatchEvent(PhoneVerificationViewModel.Event.OnCountrySelected(it)) diff --git a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt index 17a09fbda..6832454a6 100644 --- a/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt +++ b/apps/flipcash/features/contact-verification/src/main/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModel.kt @@ -61,6 +61,7 @@ internal class PhoneVerificationViewModel @Inject constructor( val canSendCode: Boolean = false, val formattedPhone: String = "", val selectedLocale: CountryLocale = CountryLocale.Stub, + val countryLocales: List = emptyList(), val sendingCode: LoadingSuccessState = LoadingSuccessState(), val verifyingCode: LoadingSuccessState = LoadingSuccessState(), val isResendTimerRunning: Boolean = false, @@ -103,6 +104,7 @@ internal class PhoneVerificationViewModel @Inject constructor( data object OnPhoneVerificationComplete : Event data object OnMaxAttemptsReached : Event + data class OnCountryLocalesLoaded(val locales: List) : Event } private var timer: Timer? = null @@ -122,8 +124,10 @@ internal class PhoneVerificationViewModel @Inject constructor( // functions (formatting may be degraded until a later load succeeds). trace(message = "PhoneUtils.ensureLoaded failed: $e", type = TraceType.Error) } - if (stateFlow.value.selectedLocale.isStub()) { - dispatchEvent(Event.OnCountrySelected(phoneUtils.defaultCountryLocale)) + dispatchEvent(Event.OnCountryLocalesLoaded(phoneUtils.countryLocales)) + val loaded = phoneUtils.defaultCountryLocale + if (stateFlow.value.selectedLocale.isStub() && !loaded.isStub()) { + dispatchEvent(Event.OnCountrySelected(loaded)) } observePhoneNumberInput() } @@ -415,6 +419,8 @@ internal class PhoneVerificationViewModel @Inject constructor( ) ) } + + is Event.OnCountryLocalesLoaded -> { state -> state.copy(countryLocales = event.locales) } } } } diff --git a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelTest.kt b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelTest.kt index 48e695948..d903c4387 100644 --- a/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelTest.kt +++ b/apps/flipcash/features/contact-verification/src/test/kotlin/com/flipcash/app/contact/verification/internal/phone/PhoneVerificationViewModelTest.kt @@ -31,6 +31,7 @@ class PhoneVerificationViewModelTest { private val phoneUtils: PhoneUtils = mockk(relaxed = true) { coEvery { ensureLoaded() } returns Unit every { defaultCountryLocale } returns testLocale + every { countryLocales } returns listOf(testLocale) every { formatNumber(any(), any(), any()) } returns "" every { isPhoneNumberValid(any(), any()) } returns false every { parseInternationalNumber(any()) } returns null @@ -80,4 +81,17 @@ class PhoneVerificationViewModelTest { advanceUntilIdle() assertEquals(testLocale, viewModel.stateFlow.value.selectedLocale) } + + @Test + fun `countryLocales populated in state after ensureLoaded completes`() = runTest(mainCoroutineRule.dispatcher) { + val dispatchers = TestDispatchers(testScheduler) + val viewModel = createViewModel(dispatchers) + + // Before advancing: list is empty (not read eagerly) + assertEquals(emptyList(), viewModel.stateFlow.value.countryLocales) + + // After advancing: OnCountryLocalesLoaded dispatched with phoneUtils.countryLocales + advanceUntilIdle() + assertEquals(listOf(testLocale), viewModel.stateFlow.value.countryLocales) + } } diff --git a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt index aa29c6833..0aba6799d 100644 --- a/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt +++ b/apps/flipcash/shared/contacts/src/main/kotlin/com/flipcash/app/contacts/ContactCoordinator.kt @@ -404,6 +404,11 @@ class ContactCoordinator @Inject constructor( private suspend fun performSync(): Result { if (cluster.value == null) return Result.failure(IllegalStateException("No active session")) + // Ensure libphonenumber metadata is loaded before formatting any E.164 numbers. + // This is normally a no-op (pre-warmed at app startup) but guards against the + // first sync racing ahead of the warm-up, which would produce empty region hints. + phoneUtils.ensureLoaded() + _state.update { it.copy(syncState = SyncState.Syncing) } try {