diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt index 2cc6b21d695..36e54c49626 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByActivity.kt @@ -134,15 +134,13 @@ class FindByActivity : PassphraseRequiredActivity() { onNavigationClick = { finishAfterTransition() }, navigationIconPainter = painterResource(id = R.drawable.symbol_arrow_start_24) ) { - val context = LocalContext.current - Content( paddingValues = it, state = state, onUserEntryChanged = viewModel::onUserEntryChanged, onNextClick = { lifecycleScope.launch { - when (val result = viewModel.onNextClicked(context)) { + when (val result = viewModel.onNextClicked()) { is FindByResult.Success -> { setResult(RESULT_OK, Intent().putExtra(RECIPIENT_ID, result.recipientId)) finishAfterTransition() @@ -191,10 +189,6 @@ class FindByActivity : PassphraseRequiredActivity() { val body = if (state.mode == FindByMode.USERNAME) { stringResource(id = R.string.FindByActivity__s_is_not_a_valid_username, state.userEntry) } else { - val formattedNumber = remember(state.userEntry) { - val cleansed = state.userEntry.removePrefix(state.selectedCountry.countryCode.toString()) - E164Util.formatAsE164WithCountryCodeForDisplay(state.selectedCountry.countryCode.toString(), cleansed) - } stringResource(id = R.string.FindByActivity__s_is_not_a_valid_phone_number, state.userEntry) } diff --git a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt index bea0fc34414..d07cbd04c0a 100644 --- a/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt +++ b/app/src/main/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModel.kt @@ -5,7 +5,6 @@ package org.thoughtcrime.securesms.recipients.ui.findby -import android.content.Context import androidx.annotation.WorkerThread import androidx.compose.runtime.State import androidx.compose.runtime.mutableStateOf @@ -43,13 +42,13 @@ class FindByViewModel( internalState.value = state.value.copy(selectedCountry = country) } - suspend fun onNextClicked(context: Context): FindByResult { + suspend fun onNextClicked(): FindByResult { internalState.value = state.value.copy(isLookupInProgress = true) val findByResult = viewModelScope.async(context = Dispatchers.IO) { if (state.value.mode == FindByMode.USERNAME) { performUsernameLookup() } else { - performPhoneLookup(context) + performPhoneLookup() } }.await() @@ -66,14 +65,14 @@ class FindByViewModel( } return when (val result = UsernameRepository.fetchAciForUsername(usernameString = username.removePrefix("@"))) { - UsernameRepository.UsernameAciFetchResult.NetworkError -> FindByResult.NotFound() + UsernameRepository.UsernameAciFetchResult.NetworkError -> FindByResult.NetworkError UsernameRepository.UsernameAciFetchResult.NotFound -> FindByResult.NotFound() is UsernameRepository.UsernameAciFetchResult.Success -> FindByResult.Success(Recipient.externalUsername(result.aci, username).id) } } @WorkerThread - private fun performPhoneLookup(context: Context): FindByResult { + private fun performPhoneLookup(): FindByResult { val stateSnapshot = state.value val countryCode = stateSnapshot.selectedCountry.countryCode val nationalNumber = stateSnapshot.userEntry.removePrefix(countryCode.toString()) diff --git a/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModelTest.kt b/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModelTest.kt new file mode 100644 index 00000000000..82b10c67cd7 --- /dev/null +++ b/app/src/test/java/org/thoughtcrime/securesms/recipients/ui/findby/FindByViewModelTest.kt @@ -0,0 +1,277 @@ +/* + * Copyright 2025 Signal Messenger, LLC + * SPDX-License-Identifier: AGPL-3.0-only + */ + +package org.thoughtcrime.securesms.recipients.ui.findby + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkObject +import io.mockk.mockkStatic +import io.mockk.unmockkObject +import io.mockk.unmockkStatic +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.thoughtcrime.securesms.dependencies.AppDependencies +import org.thoughtcrime.securesms.profiles.manage.UsernameRepository +import org.thoughtcrime.securesms.recipients.LiveRecipientCache +import org.thoughtcrime.securesms.recipients.Recipient +import org.thoughtcrime.securesms.recipients.RecipientId +import org.thoughtcrime.securesms.recipients.RecipientRepository +import org.thoughtcrime.securesms.registration.ui.countrycode.Country +import org.thoughtcrime.securesms.registration.ui.countrycode.CountryUtils +import org.whispersystems.signalservice.api.push.ServiceId +import java.util.Optional + +class FindByViewModelTest { + + private val liveRecipientCache = mockk(relaxed = true) + private lateinit var viewModel: FindByViewModel + + @Before + fun setup() { + mockkStatic(AppDependencies::class) + every { AppDependencies.recipientCache } returns liveRecipientCache + every { Recipient.self() } returns mockk { + every { e164 } returns Optional.of("+15551234") + } + } + + @After + fun tearDown() { + unmockkStatic(AppDependencies::class) + } + + @Test + fun `Given phone number mode, when I change user entry, then I expect digits only`() { + viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) + + viewModel.onUserEntryChanged("123abc456") + val result = viewModel.state.value.userEntry + + assertEquals("123456", result) + } + + @Test + fun `Given username mode, when I change user entry, then I expect unaltered value`() { + viewModel = FindByViewModel(FindByMode.USERNAME) + + viewModel.onUserEntryChanged("username123") + val result = viewModel.state.value.userEntry + + assertEquals("username123", result) + } + + @Test + fun `Given a selected country, when I update it, then I expect the state to reflect it`() { + viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) + val country = Country(emoji = "", name = "United States", countryCode = 1, regionCode = "US") + + viewModel.onCountrySelected(country) + val result = viewModel.state.value.selectedCountry + + assertEquals(country, result) + } + + @Test + fun `Given invalid username, when I click next, then I expect InvalidEntry`() = runTest { + viewModel = FindByViewModel(FindByMode.USERNAME) + + viewModel.onUserEntryChanged("invalid username") + val result = viewModel.onNextClicked() + + assertTrue(result is FindByResult.InvalidEntry) + } + + @Test + fun `Given empty phone number, when I click next, then I expect InvalidEntry`() = runTest { + viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) + + viewModel.onUserEntryChanged("") + mockkStatic(RecipientRepository::class).apply { + every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.LookupResult.InvalidEntry + } + + val result = viewModel.onNextClicked() + + assertTrue(result is FindByResult.InvalidEntry) + + unmockkObject(RecipientRepository) + } + + @Test + fun `Given valid phone lookup, when I click next, then I expect Success`() = runTest { + val recipientId = RecipientId.from(123L) + + mockkStatic(RecipientRepository::class).apply { + every { RecipientRepository.lookupNewE164("+15551234") } returns + RecipientRepository.LookupResult.Success(recipientId) + } + + viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) + val country = Country(emoji = "πŸ‡ΊπŸ‡Έ", name = "United States", countryCode = 1, regionCode = "US") + + viewModel.onCountrySelected(country) + viewModel.onUserEntryChanged("5551234") + + val result = viewModel.onNextClicked() + + assertTrue(result is FindByResult.Success) + assertEquals((result as FindByResult.Success).recipientId, recipientId) + + unmockkObject(RecipientRepository) + } + + @Test + fun `Given unknown phone lookup, when I click next, then I expect NotFound`() = runTest { + val recipientId = RecipientId.from(123L) + + mockkStatic(RecipientRepository::class).apply { + every { RecipientRepository.lookupNewE164(any()) } returns RecipientRepository.LookupResult.NotFound(recipientId) + } + + viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) + viewModel.onUserEntryChanged("0000000000") + + val result = viewModel.onNextClicked() + + assertTrue(result is FindByResult.NotFound) + assertEquals((result as FindByResult.NotFound).recipientId, recipientId) + + unmockkObject(RecipientRepository) + } + + @Test + fun `Given matching country name, when I filter countries, then I expect matched countries`() { + val countries = listOf( + Country(emoji = "πŸ‡ΊπŸ‡Έ", name = "United States", countryCode = 1, regionCode = "US"), + Country(emoji = "πŸ‡¨πŸ‡¦", name = "Canada", countryCode = 1, regionCode = "CA"), + Country(emoji = "πŸ‡¬πŸ‡§", name = "United Kingdom", countryCode = 44, regionCode = "GB") + ) + + mockkObject(CountryUtils).apply { + every { CountryUtils.getCountries() } returns countries + } + + viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) + + viewModel.filterCountries("United") + val result = viewModel.state.value.filteredCountries + + assertEquals(2, result.size) + assertTrue(result.any { it.name == "United States" }) + assertTrue(result.any { it.name == "United Kingdom" }) + + unmockkObject(CountryUtils) + } + + @Test + fun `Given matching country code, when I filter countries, then I expect matched countries`() { + val countries = listOf( + Country(emoji = "πŸ‡ΊπŸ‡Έ", name = "United States", countryCode = 1, regionCode = "US"), + Country(emoji = "πŸ‡¨πŸ‡¦", name = "Canada", countryCode = 1, regionCode = "CA"), + Country(emoji = "πŸ‡¬πŸ‡§", name = "United Kingdom", countryCode = 44, regionCode = "GB") + ) + + mockkObject(CountryUtils).apply { + every { CountryUtils.getCountries() } returns countries + } + + viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) + + viewModel.filterCountries("1") + val result = viewModel.state.value.filteredCountries + + assertEquals(2, result.size) + assertTrue(result.any { it.name == "United States" }) + assertTrue(result.any { it.name == "Canada" }) + + unmockkObject(CountryUtils) + } + + @Test + fun `Given empty country filter, when I filter countries, then I expect empty countries list`() { + val countries = listOf( + Country(emoji = "πŸ‡ΊπŸ‡Έ", name = "United States", countryCode = 1, regionCode = "US"), + Country(emoji = "πŸ‡¨πŸ‡¦", name = "Canada", countryCode = 1, regionCode = "CA"), + Country(emoji = "πŸ‡¬πŸ‡§", name = "United Kingdom", countryCode = 44, regionCode = "GB") + ) + + mockkObject(CountryUtils).apply { + every { CountryUtils.getCountries() } returns countries + } + + viewModel = FindByViewModel(FindByMode.PHONE_NUMBER) + + viewModel.filterCountries("") + val result = viewModel.state.value.filteredCountries + + assertEquals(0, result.size) + + unmockkObject(CountryUtils) + } + + @Test + fun `Given username not found, when I click next, then I expect NotFound`() = runTest { + mockkStatic(UsernameRepository::class) + every { UsernameRepository.fetchAciForUsername("john") } returns UsernameRepository.UsernameAciFetchResult.NotFound + + viewModel = FindByViewModel(FindByMode.USERNAME) + viewModel.onUserEntryChanged("@john") + + val result = viewModel.onNextClicked() + + assertTrue(result is FindByResult.NotFound) + + unmockkObject(UsernameRepository) + } + + @Test + fun `Given username fetch network error, when I click next, then I expect NetworkError`() = runTest { + mockkStatic(UsernameRepository::class) + every { UsernameRepository.fetchAciForUsername("jane") } returns UsernameRepository.UsernameAciFetchResult.NetworkError + + viewModel = FindByViewModel(FindByMode.USERNAME) + viewModel.onUserEntryChanged("@jane") + + val result = viewModel.onNextClicked() + + assertTrue(result is FindByResult.NetworkError) + + unmockkObject(UsernameRepository) + } + + @Test + fun `Given valid username, when I click next, then I expect Success`() = runTest { + val aci: ServiceId.ACI = mockk(relaxed = true) + val username = "@doe" + val recipientId = RecipientId.from(456L) + + mockkStatic(UsernameRepository::class).apply { + every { + UsernameRepository.fetchAciForUsername("doe") // stripped @ + } returns UsernameRepository.UsernameAciFetchResult.Success(aci) + } + + mockkObject(Recipient) + val mockRecipient = mockk() + every { mockRecipient.id } returns recipientId + every { Recipient.externalUsername(aci, username) } returns mockRecipient + + viewModel = FindByViewModel(FindByMode.USERNAME) + viewModel.onUserEntryChanged(username) + + val result = viewModel.onNextClicked() + + assertTrue(result is FindByResult.Success) + assertEquals(recipientId, (result as FindByResult.Success).recipientId) + + unmockkStatic(UsernameRepository::class) + unmockkObject(Recipient) + } +}