diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2b9c88a..b824380 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -139,10 +139,11 @@ dependencies { testRuntimeOnly(libs.junit.jupiter.engine) testImplementation(libs.junit.jupiter.params) testRuntimeOnly(libs.junit.platform.launcher) + testImplementation(libs.kotlinx.coroutines.test) testImplementation(libs.mockito.core) testImplementation(libs.mockito.kotlin) testImplementation(libs.mockito.junit.jupiter) - testImplementation(libs.kotlinx.coroutines.test) + testImplementation(libs.turbine) androidTestImplementation(libs.androidx.junit) androidTestImplementation(libs.androidx.espresso.core) androidTestImplementation(libs.androidx.espresso.intents) diff --git a/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt index 049685f..fb22c81 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt @@ -71,4 +71,20 @@ class MainActivityDeeplinkTest { composeTestRule.waitForIdle() composeTestRule.onNodeWithText(revision).assertExists() } + + @Test + fun testDeeplink_withAuthorEmail_populatesProfileScreen() { + val email = "tthibaud@mozilla.com" + val encodedEmail = "tthibaud%40mozilla.com" + val deeplinkUri = Uri.parse("https://treeherder.mozilla.org/jobs?repo=try&author=$encodedEmail") + val intent = Intent(ApplicationProvider.getApplicationContext(), MainActivity::class.java).apply { + action = Intent.ACTION_VIEW + data = deeplinkUri + } + + ActivityScenario.launch(intent).use { + composeTestRule.waitForIdle() + composeTestRule.onNodeWithText(email).assertExists() + } + } } diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 13af00a..e792b87 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -28,6 +28,7 @@ import org.mozilla.tryfox.ui.screens.ProfileViewModel import org.mozilla.tryfox.ui.screens.TryFoxMainScreen import org.mozilla.tryfox.ui.theme.TryFoxTheme import java.io.File +import java.net.URLDecoder sealed class NavScreen(val route: String) { data object Home : NavScreen("home") @@ -36,6 +37,7 @@ sealed class NavScreen(val route: String) { fun createRoute(project: String, revision: String) = "treeherder_search/$project/$revision" } data object Profile : NavScreen("profile") + data object ProfileByEmail : NavScreen("profile_by_email?email={email}") } class MainActivity : ComponentActivity() { @@ -158,6 +160,28 @@ class MainActivity : ComponentActivity() { profileViewModel = profileViewModel, ) } + composable( + route = NavScreen.ProfileByEmail.route, + arguments = listOf(navArgument("email") { type = NavType.StringType }), + deepLinks = listOf(navDeepLink { uriPattern = "https://treeherder.mozilla.org/jobs?repo={repo}&author={email}" }), + ) { backStackEntry -> + val profileViewModel: ProfileViewModel = koinViewModel() + val encodedEmail = backStackEntry.arguments?.getString("email") + + LaunchedEffect(encodedEmail) { + encodedEmail?.let { + val email = URLDecoder.decode(it, "UTF-8") + profileViewModel.updateAuthorEmail(email) + profileViewModel.searchByAuthor() + } + } + + profileViewModel.onInstallApk = ::installApk + ProfileScreen( + onNavigateUp = { localNavController.popBackStack() }, + profileViewModel = profileViewModel, + ) + } } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt index 55415ac..8c2d514 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.material3.rememberTooltipState import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember @@ -209,6 +210,12 @@ fun ProfileScreen( val errorMessage by profileViewModel.errorMessage.collectAsState() val cacheState by profileViewModel.cacheState.collectAsState() + LaunchedEffect(Unit) { + if (authorEmail.isBlank()) { + profileViewModel.loadLastSearchedEmail() + } + } + val isDownloading = remember(pushes) { pushes.any { push -> push.jobs.any { job -> diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt index 0c5552d..cfde4ea 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileViewModel.kt @@ -56,10 +56,6 @@ class ProfileViewModel( init { logcat(LogPriority.DEBUG, TAG) { "Initializing ProfileViewModel" } - viewModelScope.launch { - _authorEmail.value = userDataRepository.lastSearchedEmailFlow.first() - logcat(LogPriority.DEBUG, TAG) { "Initial author email loaded: ${_authorEmail.value}" } - } cacheManager.cacheState.onEach { state -> if (state is CacheManagementState.IdleEmpty) { val updatedPushes = _pushes.value.map { @@ -78,6 +74,16 @@ class ProfileViewModel( }.launchIn(viewModelScope) } + fun loadLastSearchedEmail() { + viewModelScope.launch { + val lastEmail = userDataRepository.lastSearchedEmailFlow.first() + if (lastEmail.isNotBlank() && _authorEmail.value.isBlank()) { + _authorEmail.value = lastEmail + logcat(LogPriority.DEBUG, TAG) { "Initial author email loaded: ${_authorEmail.value}" } + } + } + } + fun updateAuthorEmail(email: String) { logcat(LogPriority.DEBUG, TAG) { "Updating author email to: $email" } _authorEmail.value = email diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt index c76b904..5d50877 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt @@ -1,21 +1,16 @@ package org.mozilla.tryfox.ui.screens +import app.cash.turbine.test import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.Assertions.assertNotNull -import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension -import org.mockito.kotlin.whenever import org.mozilla.tryfox.data.IFenixRepository import org.mozilla.tryfox.data.UserDataRepository import org.mozilla.tryfox.data.managers.FakeCacheManager @@ -25,60 +20,48 @@ import java.io.File @ExtendWith(MockitoExtension::class) class ProfileViewModelTest { - @JvmField - @RegisterExtension - val mainCoroutineRule = MainCoroutineRule() - private lateinit var viewModel: ProfileViewModel - private lateinit var fakeCacheManager: FakeCacheManager + private lateinit var cacheManager: FakeCacheManager @Mock - private lateinit var mockFenixRepository: IFenixRepository + private lateinit var fenixRepository: IFenixRepository @Mock - private lateinit var mockUserDataRepository: UserDataRepository + private lateinit var userDataRepository: UserDataRepository @TempDir lateinit var tempCacheDir: File @BeforeEach fun setUp() = runTest { - fakeCacheManager = FakeCacheManager(tempCacheDir) - - // Mock the behavior of userDataRepository - whenever(mockUserDataRepository.lastSearchedEmailFlow).thenReturn(flowOf("test@example.com")) + cacheManager = FakeCacheManager(tempCacheDir) viewModel = ProfileViewModel( - fenixRepository = mockFenixRepository, - userDataRepository = mockUserDataRepository, - cacheManager = fakeCacheManager, + fenixRepository = fenixRepository, + userDataRepository = userDataRepository, + cacheManager = cacheManager, ) } @AfterEach fun tearDown() { - fakeCacheManager.reset() + cacheManager.reset() } @Test - fun `init loads last searched email`() = runTest { - advanceUntilIdle() - assertEquals("test@example.com", viewModel.authorEmail.value) - } + fun `updateAuthorEmail should update the authorEmail state`() = runTest { + // Given + val viewModel = ProfileViewModel(fenixRepository, userDataRepository, cacheManager) + val newEmail = "test@example.com" - @Test - fun `searchByAuthor with blank email sets error`() = runTest { - viewModel.updateAuthorEmail("") - viewModel.searchByAuthor() - advanceUntilIdle() - assertNotNull(viewModel.errorMessage.value) - assertTrue(viewModel.pushes.value.isEmpty()) - } + viewModel.authorEmail.test { + assertEquals("", awaitItem()) // Consume initial value - @Test - fun `clearAppCache calls cacheManager`() = runTest { - viewModel.clearAppCache() - advanceUntilIdle() - assertTrue(fakeCacheManager.clearCacheCalled) + // When + viewModel.updateAuthorEmail(newEmail) + + // Then + assertEquals(newEmail, awaitItem()) + } } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 37a0b86..87c16f9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -25,6 +25,7 @@ mockitoKotlin = "6.0.0" logcat = "0.4" espressoIntents = "3.7.0" # Added javax.inject version koin = "4.1.1" +turbine = "1.1.0" composeMaterial = "1.6.8" [libraries] @@ -66,6 +67,7 @@ logcat = { group = "com.squareup.logcat", name = "logcat", version.ref = "logcat androidx-espresso-intents = { group = "androidx.test.espresso", name = "espresso-intents", version.ref = "espressoIntents" } # Added javax.inject library koin-android = { group = "io.insert-koin", name = "koin-android", version.ref = "koin" } koin-androidx-compose = { group = "io.insert-koin", name = "koin-androidx-compose", version.ref = "koin" } +turbine = { group = "app.cash.turbine", name = "turbine", version.ref = "turbine" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }