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
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -48,7 +51,7 @@ internal class PhoneVerificationViewModel @Inject constructor(
private val resources: ResourceHelper,
private val dispatchers: DispatcherProvider,
) : BaseViewModel<PhoneVerificationViewModel.State, PhoneVerificationViewModel.Event>(
initialState = State(selectedLocale = phoneUtils.defaultCountryLocale),
initialState = State(),
updateStateForEvent = updateStateForEvent,
defaultDispatcher = dispatchers.Default,
) {
Expand All @@ -58,6 +61,7 @@ internal class PhoneVerificationViewModel @Inject constructor(
val canSendCode: Boolean = false,
val formattedPhone: String = "",
val selectedLocale: CountryLocale = CountryLocale.Stub,
val countryLocales: List<CountryLocale> = emptyList(),
val sendingCode: LoadingSuccessState = LoadingSuccessState(),
val verifyingCode: LoadingSuccessState = LoadingSuccessState(),
val isResendTimerRunning: Boolean = false,
Expand Down Expand Up @@ -100,59 +104,33 @@ internal class PhoneVerificationViewModel @Inject constructor(
data object OnPhoneVerificationComplete : Event

data object OnMaxAttemptsReached : Event
data class OnCountryLocalesLoaded(val locales: List<CountryLocale>) : Event
}

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)
}
dispatchEvent(Event.OnCountryLocalesLoaded(phoneUtils.countryLocales))
val loaded = phoneUtils.defaultCountryLocale
if (stateFlow.value.selectedLocale.isStub() && !loaded.isStub()) {
dispatchEvent(Event.OnCountrySelected(loaded))
}
observePhoneNumberInput()
}

eventFlow
.filterIsInstance<Event.OnSendCodeClicked>()
Expand Down Expand Up @@ -259,6 +237,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) {
Expand Down Expand Up @@ -390,6 +419,8 @@ internal class PhoneVerificationViewModel @Inject constructor(
)
)
}

is Event.OnCountryLocalesLoaded -> { state -> state.copy(countryLocales = event.locales) }
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
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 { countryLocales } returns listOf(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)
}

@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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,11 @@ class ContactCoordinator @Inject constructor(
private suspend fun performSync(): Result<Unit> {
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 {
Expand Down
Loading
Loading