diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt new file mode 100644 index 0000000..2f574f5 --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt @@ -0,0 +1,32 @@ +package org.mozilla.tryfox.data + +import org.mozilla.tryfox.data.managers.IntentManager +import java.io.File + +/** + * A fake implementation of [IntentManager] for use in instrumented tests. + * This class allows for verifying that the `installApk` method is called with the correct file. + */ +class FakeIntentManager() : IntentManager { + + /** + * A boolean flag to indicate whether the `installApk` method was called. + */ + val wasInstallApkCalled: Boolean + get() = installedFile != null + + /** + * The file that was passed to the `installApk` method. + */ + var installedFile: File? = null + private set + + /** + * Overrides the `installApk` method to capture the file and set the `wasInstallApkCalled` flag. + * + * @param file The file to be "installed". + */ + override fun installApk(file: File) { + installedFile = file + } +} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt index 8d36772..ec2213b 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt @@ -1,23 +1,24 @@ package org.mozilla.tryfox.ui.screens import androidx.compose.ui.semantics.SemanticsProperties -import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assert +import androidx.compose.ui.test.hasText import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 -import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mozilla.tryfox.data.FakeCacheManager import org.mozilla.tryfox.data.FakeFenixRepository +import org.mozilla.tryfox.data.FakeIntentManager import org.mozilla.tryfox.data.FakeUserDataRepository import org.mozilla.tryfox.data.UserDataRepository import org.mozilla.tryfox.data.managers.CacheManager -import java.io.File @RunWith(AndroidJUnit4::class) class ProfileScreenTest { @@ -28,11 +29,7 @@ class ProfileScreenTest { private val fenixRepository = FakeFenixRepository(downloadProgressDelayMillis = 100L) private val userDataRepository: UserDataRepository = FakeUserDataRepository() private val cacheManager: CacheManager = FakeCacheManager() - private val profileViewModel = ProfileViewModel( - fenixRepository = fenixRepository, - userDataRepository = userDataRepository, - cacheManager = cacheManager, - ) + private val intentManager = FakeIntentManager() private val emailInputTag = "profile_email_input" private val emailClearButtonTag = "profile_email_clear_button" private val searchButtonTag = "profile_search_button" @@ -45,11 +42,13 @@ class ProfileScreenTest { @Test fun searchPushesAndCheckDownloadAndInstallStates() { - var capturedApkFile: File? = null - - profileViewModel.onInstallApk = { apkFile -> - capturedApkFile = apkFile - } + val profileViewModel = ProfileViewModel( + fenixRepository = fenixRepository, + userDataRepository = userDataRepository, + cacheManager = cacheManager, + intentManager = intentManager, + authorEmail = null, + ) composeTestRule.setContent { ProfileScreen( @@ -75,25 +74,39 @@ class ProfileScreenTest { .performClick() composeTestRule.waitUntil("Download button enters loading state", longTimeoutMillis) { - tryOrFalse { - composeTestRule.onNodeWithTag(downloadButtonLoadingTag, useUnmergedTree = true).assertIsDisplayed() - } + composeTestRule.onAllNodesWithTag(downloadButtonLoadingTag, useUnmergedTree = true) + .fetchSemanticsNodes().isNotEmpty() } composeTestRule.waitUntil("Download button enters install state", longTimeoutMillis) { - tryOrFalse { - composeTestRule.onNodeWithTag(downloadButtonInstallTag, useUnmergedTree = true) - .assertIsDisplayed() - } + composeTestRule.onAllNodesWithTag(downloadButtonInstallTag, useUnmergedTree = true) + .fetchSemanticsNodes().isNotEmpty() } - assertNotNull("APK file should have been captured by onInstallApk callback", capturedApkFile) + assertTrue( + "APK file should have been captured by onInstallApk callback", + intentManager.wasInstallApkCalled, + ) } - private fun tryOrFalse(block: () -> Unit): Boolean = try { - block() - true - } catch (_: AssertionError) { - false + @Test + fun test_profileScreen_displays_initial_authorEmail_in_searchField() { + val initialEmail = "initial@example.com" + val profileViewModelWithEmail = ProfileViewModel( + fenixRepository = fenixRepository, + userDataRepository = userDataRepository, + cacheManager = cacheManager, + intentManager = intentManager, + authorEmail = initialEmail, + ) + + composeTestRule.setContent { + ProfileScreen( + profileViewModel = profileViewModelWithEmail, + onNavigateUp = { }, + ) + } + + composeTestRule.onNodeWithTag(emailInputTag).assert(hasText(initialEmail)) } } diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index e792b87..4d34676 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -1,17 +1,12 @@ package org.mozilla.tryfox -import android.content.ActivityNotFoundException import android.content.Intent -import android.net.Uri import android.os.Bundle import android.util.Log -import android.widget.Toast import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect -import androidx.core.content.FileProvider import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -20,30 +15,58 @@ import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument import androidx.navigation.navDeepLink import org.koin.androidx.compose.koinViewModel -import org.koin.androidx.viewmodel.ext.android.viewModel +import org.koin.core.parameter.parametersOf import org.mozilla.tryfox.ui.screens.HomeScreen -import org.mozilla.tryfox.ui.screens.HomeViewModel import org.mozilla.tryfox.ui.screens.ProfileScreen -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 representing the navigation screens in the application. + * Each object corresponds to a specific route in the navigation graph. + */ sealed class NavScreen(val route: String) { + /** + * Represents the Home screen. + */ data object Home : NavScreen("home") + + /** + * Represents the Treeherder search screen without arguments. + */ data object TreeherderSearch : NavScreen("treeherder_search") + + /** + * Represents the Treeherder search screen with project and revision arguments. + */ data object TreeherderSearchWithArgs : NavScreen("treeherder_search/{project}/{revision}") { + /** + * Creates a route for the Treeherder search screen with the given project and revision. + * @param project The project name. + * @param revision The revision hash. + * @return The formatted route string. + */ fun createRoute(project: String, revision: String) = "treeherder_search/$project/$revision" } + + /** + * Represents the Profile screen. + */ data object Profile : NavScreen("profile") + + /** + * Represents the Profile screen filtered by email. + */ data object ProfileByEmail : NavScreen("profile_by_email?email={email}") } +/** + * The main activity of the TryFox application. + * This activity sets up the navigation host and handles deep links. + */ class MainActivity : ComponentActivity() { - // Inject FenixInstallerViewModel using Koin - private val tryFoxViewModel: TryFoxViewModel by viewModel() private lateinit var navController: NavHostController override fun onCreate(savedInstanceState: Bundle?) { @@ -53,7 +76,7 @@ class MainActivity : ComponentActivity() { setContent { TryFoxTheme { // Pass the Koin-injected ViewModel - AppNavigation(tryFoxViewModel) + AppNavigation() } } } @@ -67,27 +90,12 @@ class MainActivity : ComponentActivity() { } } - private fun installApk(file: File) { - val fileUri: Uri = FileProvider.getUriForFile( - this, - "${BuildConfig.APPLICATION_ID}.provider", - file, - ) - val intent = Intent(Intent.ACTION_VIEW).apply { - setDataAndType(fileUri, "application/vnd.android.package-archive") - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) - } - try { - startActivity(intent) - } catch (e: ActivityNotFoundException) { - Toast.makeText(this, "No application found to install APK", Toast.LENGTH_LONG).show() - Log.e("MainActivity", "Error installing APK", e) - } - } - + /** + * Composable function that sets up the application's navigation. + * It defines the navigation graph and handles different routes and deep links. + */ @Composable - fun AppNavigation(mainActivityViewModel: TryFoxViewModel) { + fun AppNavigation() { val localNavController = rememberNavController() this@MainActivity.navController = localNavController Log.d("MainActivity", "AppNavigation: NavController instance assigned: $localNavController") @@ -95,19 +103,16 @@ class MainActivity : ComponentActivity() { NavHost(navController = localNavController, startDestination = NavScreen.Home.route) { composable(NavScreen.Home.route) { // Inject HomeViewModel using Koin in Composable - val homeViewModel: HomeViewModel = koinViewModel() - homeViewModel.onInstallApk = ::installApk HomeScreen( onNavigateToTreeherder = { localNavController.navigate(NavScreen.TreeherderSearch.route) }, onNavigateToProfile = { localNavController.navigate(NavScreen.Profile.route) }, - homeViewModel = homeViewModel, + homeViewModel = koinViewModel(), ) } composable(NavScreen.TreeherderSearch.route) { // mainActivityViewModel is already injected and passed as a parameter - mainActivityViewModel.onInstallApk = ::installApk TryFoxMainScreen( - tryFoxViewModel = mainActivityViewModel, + tryFoxViewModel = koinViewModel(), onNavigateUp = { localNavController.popBackStack() }, ) } @@ -134,30 +139,15 @@ class MainActivity : ComponentActivity() { "MainActivity", "TreeherderSearchWithArgs composable: project='$project', revision='$revision' from NavBackStackEntry. ID: ${backStackEntry.id}", ) - - LaunchedEffect(project, revision) { - Log.d( - "MainActivity", - "TreeherderSearchWithArgs LaunchedEffect: project='$project', revision='$revision'", - ) - mainActivityViewModel.setRevisionFromDeepLinkAndSearch( - project, - revision, - ) - } - mainActivityViewModel.onInstallApk = ::installApk TryFoxMainScreen( - tryFoxViewModel = mainActivityViewModel, + tryFoxViewModel = koinViewModel { parametersOf(project, revision) }, onNavigateUp = { localNavController.popBackStack() }, ) } composable(NavScreen.Profile.route) { - // Inject ProfileViewModel using Koin in Composable - val profileViewModel: ProfileViewModel = koinViewModel() - profileViewModel.onInstallApk = ::installApk // Assuming ProfileViewModel also needs this ProfileScreen( onNavigateUp = { localNavController.popBackStack() }, - profileViewModel = profileViewModel, + profileViewModel = koinViewModel(), ) } composable( @@ -165,21 +155,13 @@ class MainActivity : ComponentActivity() { 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() - } + val email = backStackEntry.arguments?.getString("email")?.let { + URLDecoder.decode(it, "UTF-8") } - profileViewModel.onInstallApk = ::installApk ProfileScreen( onNavigateUp = { localNavController.popBackStack() }, - profileViewModel = profileViewModel, + profileViewModel = koinViewModel { parametersOf(email) }, ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index 073b898..f055885 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -27,14 +27,25 @@ import org.mozilla.tryfox.ui.models.ArtifactUiModel import org.mozilla.tryfox.ui.models.JobDetailsUiModel import java.io.File +/** + * ViewModel for the TryFox feature, responsible for fetching job and artifact data from the repository, + * managing the download and caching of artifacts, and exposing the UI state to the composable screens. + * + * @param repository The repository for fetching data from the network. + * @param cacheManager The manager for handling application cache. + * @param revision The initial revision to search for. + * @param repo The initial repository to search in. + */ class TryFoxViewModel( private val repository: IFenixRepository, private val cacheManager: CacheManager, + revision: String?, + repo: String?, ) : ViewModel() { - var revision by mutableStateOf("") + var revision by mutableStateOf(revision ?: "") private set - var selectedProject by mutableStateOf("try") + var selectedProject by mutableStateOf(repo ?: "try") private set var relevantPushComment by mutableStateOf(null) diff --git a/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt b/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt new file mode 100644 index 0000000..4b0e007 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt @@ -0,0 +1,55 @@ +package org.mozilla.tryfox.data.managers + +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.util.Log +import android.widget.Toast +import androidx.core.content.FileProvider +import org.mozilla.tryfox.BuildConfig +import java.io.File + +/** + * Interface for managing intents related to APK installation. + */ +interface IntentManager { + /** + * Initiates the installation of an APK file. + * + * @param file The APK file to install. + */ + fun installApk(file: File) +} + +/** + * Default implementation of [IntentManager] that handles APK installation using a [FileProvider]. + * + * @param context The application context. + */ +class DefaultIntentManager(private val context: Context) : IntentManager { + /** + * Creates an intent to install an APK file and starts the corresponding activity. + * If no application is found to handle the intent, a toast message is displayed. + * + * @param file The APK file to install. + */ + override fun installApk(file: File) { + val fileUri: Uri = FileProvider.getUriForFile( + context, + "${BuildConfig.APPLICATION_ID}.provider", + file, + ) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(fileUri, "application/vnd.android.package-archive") + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + try { + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + Toast.makeText(context, "No application found to install APK", Toast.LENGTH_LONG).show() + Log.e("IntentManager", "Error installing APK", e) + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt index b734fce..6a96ad8 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -23,6 +23,8 @@ import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.UserDataRepository import org.mozilla.tryfox.data.managers.CacheManager import org.mozilla.tryfox.data.managers.DefaultCacheManager +import org.mozilla.tryfox.data.managers.DefaultIntentManager +import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.network.ApiService import org.mozilla.tryfox.ui.screens.HomeViewModel import org.mozilla.tryfox.ui.screens.ProfileViewModel @@ -71,12 +73,13 @@ val repositoryModule = module { single { MozillaArchiveRepositoryImpl(get()) } single { DefaultMozillaPackageManager(androidContext()) } single { DefaultCacheManager(androidContext().cacheDir, get(named("IODispatcher"))) } + single { DefaultIntentManager(androidContext()) } } val viewModelModule = module { - viewModel { TryFoxViewModel(get(), get()) } - viewModel { HomeViewModel(get(), get(), get(), get(), get(named("IODispatcher"))) } - viewModel { ProfileViewModel(get(), get(), get()) } + viewModel { params -> TryFoxViewModel(get(), get(), params.getOrNull(), params.getOrNull()) } + viewModel { HomeViewModel(get(), get(), get(), get(), get(), get(named("IODispatcher"))) } + viewModel { params -> ProfileViewModel(get(), get(), get(), get(), params.getOrNull()) } } val appModules = listOf(dispatchersModule, networkModule, repositoryModule, viewModelModule) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt index 7ed25fa..4bfe367 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt @@ -49,6 +49,14 @@ import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.util.parseDateToMillis import java.io.File +/** + * Composable function for the Home screen, which displays a list of available apps and allows users to interact with them. + * + * @param modifier The modifier to be applied to the component. + * @param onNavigateToTreeherder Callback to navigate to the Treeherder search screen. + * @param onNavigateToProfile Callback to navigate to the Profile screen. + * @param homeViewModel The ViewModel for the Home screen. + */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun HomeScreen( @@ -92,7 +100,9 @@ fun HomeScreen( IconButton(onClick = onNavigateToTreeherder) { Icon( imageVector = Icons.Filled.Search, - contentDescription = stringResource(id = R.string.home_search_treeherder_button_description), + contentDescription = stringResource( + id = R.string.home_search_treeherder_button_description, + ), ) } val tooltipState = rememberTooltipState() @@ -131,7 +141,7 @@ fun HomeScreen( CircularProgressIndicator() Text( stringResource(id = R.string.home_loading_initial_data), - modifier = Modifier.padding(top = 70.dp), // Adjust as needed to place below indicator + modifier = Modifier.padding(top = 70.dp), ) } } @@ -149,7 +159,7 @@ fun HomeScreen( AppComponent( app = app, onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, - onInstallClick = { homeViewModel.onInstallApk?.invoke(it) }, + onInstallClick = { homeViewModel.installApk(it) }, onOpenAppClick = { homeViewModel.openApp(it) }, onDateSelected = { appName, date -> homeViewModel.onDateSelected( @@ -174,6 +184,17 @@ fun HomeScreen( } } +/** + * Composable function for displaying a single app component, which includes information about the app and actions that can be performed. + * + * @param app The UI model for the app. + * @param onDownloadClick Callback for when the download button is clicked. + * @param onInstallClick Callback for when the install button is clicked. + * @param onOpenAppClick Callback for when the open app button is clicked. + * @param onDateSelected Callback for when a date is selected in the date picker. + * @param dateValidator A function to validate the selectable dates in the date picker. + * @param onClearDate Callback for when the selected date is cleared. + */ @Composable fun AppComponent( app: AppUiModel, diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt index 9c1eb74..9ed0d50 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt @@ -4,7 +4,6 @@ import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -25,6 +24,7 @@ import org.mozilla.tryfox.data.MozillaArchiveRepository import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.CacheManager +import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.ParsedNightlyApk import org.mozilla.tryfox.ui.models.AbiUiModel @@ -37,13 +37,24 @@ import org.mozilla.tryfox.util.REFERENCE_BROWSER import java.io.File import kotlin.collections.mapValues +/** + * ViewModel for the Home screen, responsible for fetching and displaying nightly builds of different Mozilla apps. + * + * @param mozillaArchiveRepository Repository for fetching data from the Mozilla Archive. + * @param fenixRepository Repository for fetching Fenix-related data. + * @param mozillaPackageManager Manager for interacting with installed Mozilla apps. + * @param cacheManager Manager for handling application cache. + * @param intentManager Manager for handling intents, such as APK installation. + * @param ioDispatcher The coroutine dispatcher for background operations. + */ @OptIn(FormatStringsInDatetimeFormats::class) class HomeViewModel( private val mozillaArchiveRepository: MozillaArchiveRepository, private val fenixRepository: IFenixRepository, private val mozillaPackageManager: MozillaPackageManager, private val cacheManager: CacheManager, - private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val intentManager: IntentManager, + private val ioDispatcher: CoroutineDispatcher, ) : ViewModel() { internal var deviceSupportedAbisForTesting: List? = null @@ -58,8 +69,6 @@ class HomeViewModel( deviceSupportedAbisForTesting ?: Build.SUPPORTED_ABIS?.toList() ?: emptyList() } - var onInstallApk: ((File) -> Unit)? = null - init { cacheManager.cacheState .onEach { newCacheState -> @@ -69,7 +78,9 @@ class HomeViewModel( val updatedApps = if (newCacheState is CacheManagementState.IdleEmpty) { currentState.apps.mapValues { (_, app) -> val apksResult = app.apks as? ApksResult.Success ?: return@mapValues app - val updatedApks = apksResult.apks.map { it.copy(downloadState = DownloadState.NotDownloaded) } + val updatedApks = apksResult.apks.map { + it.copy(downloadState = DownloadState.NotDownloaded) + } app.copy(apks = ApksResult.Success(updatedApks)) } } else { @@ -79,7 +90,11 @@ class HomeViewModel( currentState.copy( apps = updatedApps, cacheManagementState = newCacheState, - isDownloadingAnyFile = if (newCacheState is CacheManagementState.IdleEmpty) false else currentState.isDownloadingAnyFile, + isDownloadingAnyFile = if (newCacheState is CacheManagementState.IdleEmpty) { + false + } else { + currentState.isDownloadingAnyFile + }, ) } } @@ -109,7 +124,7 @@ class HomeViewModel( } fun initialLoad() { - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { _homeScreenState.value = HomeScreenState.InitialLoading _isRefreshing.value = true cacheManager.checkCacheStatus() // Initial check @@ -119,7 +134,7 @@ class HomeViewModel( } fun refreshData() { - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { _isRefreshing.value = true fetchData() _isRefreshing.value = false @@ -164,7 +179,9 @@ class HomeViewModel( val latestApks = getLatestApks(result.data) ApksResult.Success(convertParsedApksToUiModels(latestApks)) } - is NetworkResult.Error -> ApksResult.Error("Error fetching $appName nightly builds: ${result.message}") + is NetworkResult.Error -> ApksResult.Error( + "Error fetching $appName nightly builds: ${result.message}", + ) } AppUiModel( name = appName, @@ -219,7 +236,11 @@ class HomeViewModel( DownloadState.NotDownloaded } - val uniqueKeyPath = if (datePart.isNullOrBlank()) parsedApk.appName else "${parsedApk.appName}/$datePart" + val uniqueKeyPath = if (datePart.isNullOrBlank()) { + parsedApk.appName + } else { + "${parsedApk.appName}/$datePart" + } val uniqueKey = "$uniqueKeyPath/${parsedApk.fileName}" ApkUiModel( @@ -278,7 +299,7 @@ class HomeViewModel( return } - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { updateApkDownloadStateInScreenState( apkInfo.appName, apkInfo.uniqueKey, @@ -311,7 +332,7 @@ class HomeViewModel( DownloadState.Downloaded(result.data), ) cacheManager.checkCacheStatus() // Notify cache manager about new file - onInstallApk?.invoke(result.data) + installApk(result.data) } is NetworkResult.Error -> { @@ -326,8 +347,12 @@ class HomeViewModel( } } + fun installApk(file: File) { + intentManager.installApk(file) + } + fun clearAppCache() { - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { cacheManager.clearCache() } } @@ -337,7 +362,7 @@ class HomeViewModel( return } - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { val currentState = _homeScreenState.value as? HomeScreenState.Loaded ?: return@launch val appToUpdate = currentState.apps[appName] ?: return@launch @@ -360,7 +385,9 @@ class HomeViewModel( val latestApks = getLatestApks(result.data) ApksResult.Success(convertParsedApksToUiModels(latestApks)) } - is NetworkResult.Error -> ApksResult.Error("Error fetching $appName nightly builds for $date: ${result.message}") + is NetworkResult.Error -> ApksResult.Error( + "Error fetching $appName nightly builds for $date: ${result.message}", + ) } val latestState = _homeScreenState.value as? HomeScreenState.Loaded ?: return@launch @@ -375,7 +402,7 @@ class HomeViewModel( } fun onClearDate(appName: String) { - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { val currentState = _homeScreenState.value as? HomeScreenState.Loaded ?: return@launch val appToUpdate = currentState.apps[appName] ?: return@launch @@ -396,7 +423,9 @@ class HomeViewModel( val latestApks = getLatestApks(result.data) ApksResult.Success(convertParsedApksToUiModels(latestApks)) } - is NetworkResult.Error -> ApksResult.Error("Error fetching $appName nightly builds: ${result.message}") + is NetworkResult.Error -> ApksResult.Error( + "Error fetching $appName nightly builds: ${result.message}", + ) } val latestState = _homeScreenState.value as? HomeScreenState.Loaded ?: return@launch 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 8c2d514..a0a5455 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 @@ -17,8 +17,8 @@ import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.KeyboardActions // Added -import androidx.compose.foundation.text.KeyboardOptions // Added +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material.icons.filled.Close @@ -43,18 +43,17 @@ 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 import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi // Added +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalSoftwareKeyboardController // Added +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.ImeAction // Added +import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R import org.mozilla.tryfox.data.DownloadState @@ -197,6 +196,13 @@ private fun ErrorState(errorMessage: String, modifier: Modifier = Modifier) { } } +/** + * Composable function for the Profile screen, which allows users to search for pushes by author email. + * + * @param modifier The modifier to be applied to the component. + * @param onNavigateUp Callback to navigate back to the previous screen. + * @param profileViewModel The ViewModel for the Profile screen. + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun ProfileScreen( @@ -210,12 +216,6 @@ 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 -> @@ -302,7 +302,9 @@ fun ProfileScreen( LazyColumn( contentPadding = PaddingValues(bottom = 16.dp), ) { - items(pushes, key = { push -> push.pushComment + push.author + (push.jobs.firstOrNull()?.taskId ?: "") }) { push -> + items(pushes, key = { push -> + push.pushComment + push.author + (push.jobs.firstOrNull()?.taskId ?: "") + }) { push -> ElevatedCard( modifier = Modifier.fillMaxWidth().padding(bottom = 16.dp), elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), @@ -311,7 +313,11 @@ fun ProfileScreen( modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp), ) { - PushCommentCard(comment = push.pushComment, author = push.author, revision = push.revision ?: "unknown_revision") + PushCommentCard( + comment = push.pushComment, + author = push.author, + revision = push.revision ?: "unknown_revision", + ) push.jobs.forEach { job -> JobCard(job = job, profileViewModel = profileViewModel) } @@ -376,7 +382,7 @@ private fun JobCard( DownloadButton( downloadState = it.downloadState, onDownloadClick = { profileViewModel.downloadArtifact(it) }, - onInstallClick = { file -> profileViewModel.onInstallApk?.invoke(file) }, + onInstallClick = { file -> profileViewModel.installApk(file) }, ) } } 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 cfde4ea..e787d67 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 @@ -1,7 +1,6 @@ package org.mozilla.tryfox.ui.screens import android.os.Build -import android.util.Log import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.async @@ -20,6 +19,7 @@ import org.mozilla.tryfox.data.IFenixRepository import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.UserDataRepository import org.mozilla.tryfox.data.managers.CacheManager +import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ArtifactUiModel @@ -27,17 +27,28 @@ import org.mozilla.tryfox.ui.models.JobDetailsUiModel import org.mozilla.tryfox.ui.models.PushUiModel import java.io.File +/** + * ViewModel for the Profile screen, responsible for fetching pushes and artifacts by author, managing downloads, and handling user interactions. + * + * @param fenixRepository The repository for fetching Fenix-related data. + * @param userDataRepository The repository for storing and retrieving user data, such as the last searched email. + * @param cacheManager The manager for handling application cache. + * @param intentManager The manager for handling intents, such as APK installation. + * @param authorEmail The initial author email to search for, can be null. + */ class ProfileViewModel( private val fenixRepository: IFenixRepository, private val userDataRepository: UserDataRepository, private val cacheManager: CacheManager, + private val intentManager: IntentManager, + authorEmail: String?, ) : ViewModel() { companion object { private const val TAG = "ProfileViewModel" } - private val _authorEmail = MutableStateFlow("") + private val _authorEmail = MutableStateFlow(authorEmail ?: "") val authorEmail: StateFlow = _authorEmail.asStateFlow() private val _isLoading = MutableStateFlow(false) @@ -52,34 +63,42 @@ class ProfileViewModel( val cacheState: StateFlow = cacheManager.cacheState private val deviceSupportedAbis: List by lazy { Build.SUPPORTED_ABIS.toList() } - var onInstallApk: ((File) -> Unit)? = null init { - logcat(LogPriority.DEBUG, TAG) { "Initializing ProfileViewModel" } + logcat(LogPriority.DEBUG, TAG) { "Initializing ProfileViewModel for email: $authorEmail" } cacheManager.cacheState.onEach { state -> if (state is CacheManagementState.IdleEmpty) { val updatedPushes = _pushes.value.map { it.copy( jobs = it.jobs.map { job -> - job.copy( - artifacts = job.artifacts.map { artifact -> - artifact.copy(downloadState = DownloadState.NotDownloaded) + job.copy( + artifacts = job.artifacts.map { artifact -> + artifact.copy(downloadState = DownloadState.NotDownloaded) + }, + ) }, - ) - }, ) } _pushes.value = updatedPushes } }.launchIn(viewModelScope) + + if (authorEmail != null) { + searchByAuthor() + } else { + loadLastSearchedEmail() + } } - fun loadLastSearchedEmail() { + private fun loadLastSearchedEmail() { viewModelScope.launch { val lastEmail = userDataRepository.lastSearchedEmailFlow.first() - if (lastEmail.isNotBlank() && _authorEmail.value.isBlank()) { + if (lastEmail.isNotBlank()) { _authorEmail.value = lastEmail - logcat(LogPriority.DEBUG, TAG) { "Initial author email loaded: ${_authorEmail.value}" } + logcat( + LogPriority.DEBUG, + TAG, + ) { "Initial author email loaded from storage: ${_authorEmail.value}" } } } } @@ -90,27 +109,32 @@ class ProfileViewModel( } fun searchByAuthor() { - logcat(TAG) { "searchByAuthor called for email: ${_authorEmail.value}" } - if (_authorEmail.value.isBlank()) { + val emailToSearch = _authorEmail.value + logcat(TAG) { "searchByAuthor called for email: $emailToSearch" } + if (emailToSearch.isBlank()) { _errorMessage.value = "Please enter an author email to search." logcat(LogPriority.WARN, TAG) { "Search attempt with blank email" } return } viewModelScope.launch { - userDataRepository.saveLastSearchedEmail(_authorEmail.value) + userDataRepository.saveLastSearchedEmail(emailToSearch) _isLoading.value = true _errorMessage.value = null _pushes.value = emptyList() logcat(LogPriority.DEBUG, TAG) { "Starting search..." } - when (val result = fenixRepository.getPushesByAuthor(_authorEmail.value)) { + when (val result = fenixRepository.getPushesByAuthor(emailToSearch)) { is NetworkResult.Success -> { - logcat(LogPriority.DEBUG, TAG) { "getPushesByAuthor success, processing ${result.data.results.size} pushes" } + logcat( + LogPriority.DEBUG, + TAG, + ) { "getPushesByAuthor success, processing ${result.data.results.size} pushes" } val pushesWithJobsAndArtifacts = result.data.results.map { pushResult -> async { val jobsResult = fenixRepository.getJobsForPush(pushResult.id) if (jobsResult is NetworkResult.Success) { - val filteredJobs = jobsResult.data.results.filter { it.isSignedBuild && !it.isTest } + val filteredJobs = + jobsResult.data.results.filter { it.isSignedBuild && !it.isTest } if (filteredJobs.isNotEmpty()) { val jobsWithArtifacts = filteredJobs.map { jobDetails -> async { @@ -140,7 +164,9 @@ class ProfileViewModel( } } if (determinedPushComment == null) { - determinedPushComment = pushResult.revisions.firstOrNull()?.comments ?: "No comment" + determinedPushComment = + pushResult.revisions.firstOrNull()?.comments + ?: "No comment" } PushUiModel( pushComment = determinedPushComment, @@ -149,15 +175,22 @@ class ProfileViewModel( revision = pushResult.revision, ) } else { - logcat(LogPriority.VERBOSE, TAG) { "No jobs with artifacts for push ID: ${pushResult.id}" } + logcat(LogPriority.VERBOSE, TAG) { + "No jobs with artifacts for push ID: ${pushResult.id}" + } null } } else { - logcat(LogPriority.VERBOSE, TAG) { "No signed, non-test jobs for push ID: ${pushResult.id}" } + logcat(LogPriority.VERBOSE, TAG) { + "No signed, non-test jobs for push ID: ${pushResult.id}" + } null } } else { - logcat(LogPriority.WARN, TAG) { "getJobsForPush failed for push ID: ${pushResult.id}: ${(jobsResult as NetworkResult.Error).message}" } + logcat(LogPriority.WARN, TAG) { + "getJobsForPush failed for push ID: ${pushResult.id}: " + + (jobsResult as NetworkResult.Error).message + } null } } @@ -170,6 +203,7 @@ class ProfileViewModel( logcat(TAG) { "No signed builds found for author." } } } + is NetworkResult.Error -> { logcat(LogPriority.ERROR, TAG) { "Error fetching pushes: ${result.message}" } _errorMessage.value = "Error fetching pushes: ${result.message}" @@ -186,7 +220,10 @@ class ProfileViewModel( val filteredApks = artifactsResult.data.artifacts.filter { it.name.endsWith(".apk", ignoreCase = true) } - logcat(LogPriority.VERBOSE, TAG) { "Found ${filteredApks.size} APKs for taskId: $taskId" } + logcat( + LogPriority.VERBOSE, + TAG, + ) { "Found ${filteredApks.size} APKs for taskId: $taskId" } filteredApks.map { artifact -> val artifactFileName = artifact.name.substringAfterLast('/') val downloadedFile = getDownloadedFile(artifactFileName, taskId) @@ -195,9 +232,10 @@ class ProfileViewModel( } else { DownloadState.NotDownloaded } - val isCompatible = artifact.abi != null && deviceSupportedAbis.any { deviceAbi -> - deviceAbi.equals(artifact.abi, ignoreCase = true) - } + val isCompatible = + artifact.abi != null && deviceSupportedAbis.any { deviceAbi -> + deviceAbi.equals(artifact.abi, ignoreCase = true) + } ArtifactUiModel( name = artifact.name, taskId = taskId, @@ -212,8 +250,11 @@ class ProfileViewModel( ) } } + is NetworkResult.Error -> { - logcat(LogPriority.WARN, TAG) { "fetchArtifacts error for taskId $taskId: ${artifactsResult.message}" } + logcat(LogPriority.WARN, TAG) { + "fetchArtifacts error for taskId $taskId: ${artifactsResult.message}" + } emptyList() } } @@ -224,7 +265,10 @@ class ProfileViewModel( val taskSpecificDir = File(cacheManager.getCacheDir("treeherder"), taskId) val outputFile = File(taskSpecificDir, artifactName) val exists = outputFile.exists() - logcat(LogPriority.VERBOSE, TAG) { "getDownloadedFile for $artifactName in $taskId: exists=$exists" } + logcat( + LogPriority.VERBOSE, + TAG, + ) { "getDownloadedFile for $artifactName in $taskId: exists=$exists" } return if (exists) outputFile else null } @@ -238,21 +282,35 @@ class ProfileViewModel( fun downloadArtifact(artifactUiModel: ArtifactUiModel) { val artifactFileName = artifactUiModel.name.substringAfterLast('/') val taskId = artifactUiModel.taskId - logcat(TAG) { "downloadArtifact called for: ${artifactUiModel.name}, taskId: $taskId, uniqueKey: ${artifactUiModel.uniqueKey}" } + logcat(TAG) { + "downloadArtifact called for: ${artifactUiModel.name}, taskId: $taskId, " + + "uniqueKey: ${artifactUiModel.uniqueKey}" + } - if (artifactUiModel.downloadState is DownloadState.InProgress || artifactUiModel.downloadState is DownloadState.Downloaded) { - logcat(LogPriority.WARN, TAG) { "Download attempt for already in progress or downloaded artifact: ${artifactUiModel.name}" } + if (artifactUiModel.downloadState is DownloadState.InProgress || + artifactUiModel.downloadState is DownloadState.Downloaded + ) { + logcat(LogPriority.WARN, TAG) { + "Download attempt for already in progress or downloaded artifact: ${artifactUiModel.name}" + } return } - if (taskId.isBlank()) { + if (taskId.isBlank()) { val blankTaskIdMsg = "Task ID is blank for $artifactFileName" logcat(LogPriority.ERROR, TAG) { blankTaskIdMsg } - updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.DownloadFailed(blankTaskIdMsg)) + updateArtifactDownloadState( + taskId, + artifactUiModel.name, + DownloadState.DownloadFailed(blankTaskIdMsg), + ) return } viewModelScope.launch { - logcat(LogPriority.DEBUG, TAG) { "Starting download coroutine for ${artifactUiModel.name}" } + logcat( + LogPriority.DEBUG, + TAG, + ) { "Starting download coroutine for ${artifactUiModel.name}" } updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.InProgress(0f)) val downloadUrl = artifactUiModel.downloadUrl @@ -261,7 +319,10 @@ class ProfileViewModel( val outputDir = File(cacheManager.getCacheDir("treeherder"), taskId) if (!outputDir.exists()) { outputDir.mkdirs() - logcat(LogPriority.VERBOSE, TAG) { "Created output directory: ${outputDir.absolutePath}" } + logcat( + LogPriority.VERBOSE, + TAG, + ) { "Created output directory: ${outputDir.absolutePath}" } } val outputFile = File(outputDir, artifactFileName) logcat(LogPriority.DEBUG, TAG) { "Output file: ${outputFile.absolutePath}" } @@ -292,53 +353,94 @@ class ProfileViewModel( } if (shouldLog) { - logcat(LogPriority.VERBOSE, TAG) { "Download progress for ${artifactUiModel.name}: $bytesDownloaded / $totalBytes ($currentProgressFloat)" } + logcat(LogPriority.VERBOSE, TAG) { + "Download progress for ${artifactUiModel.name}: $bytesDownloaded / $totalBytes " + + "($currentProgressFloat)" + } } - updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.InProgress(currentProgressFloat)) + updateArtifactDownloadState( + taskId, + artifactUiModel.name, + DownloadState.InProgress(currentProgressFloat), + ) }, ) logcat(TAG) { "fenixRepository.downloadArtifact result for ${artifactUiModel.name}: $result" } when (result) { is NetworkResult.Success -> { - updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.Downloaded(result.data)) + updateArtifactDownloadState( + taskId, + artifactUiModel.name, + DownloadState.Downloaded(result.data), + ) cacheManager.checkCacheStatus() logcat(TAG) { "Download success for ${artifactUiModel.name}. APK is ready to be installed." } - onInstallApk?.invoke(result.data) + installApk(result.data) } + is NetworkResult.Error -> { val failureMessage = "Download failed for $artifactFileName: ${result.message}" if (result.cause != null) { - logcat(LogPriority.ERROR, TAG) { "$failureMessage\n${Log.getStackTraceString(result.cause)}" } + logcat( + LogPriority.ERROR, + TAG, + ) { "$failureMessage\n${result.cause.stackTraceToString()}" } } else { logcat(LogPriority.ERROR, TAG) { "$failureMessage (No cause available)" } } - updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.DownloadFailed(result.message)) + updateArtifactDownloadState( + taskId, + artifactUiModel.name, + DownloadState.DownloadFailed(result.message), + ) cacheManager.checkCacheStatus() } } } } - private fun updateArtifactDownloadState(taskIdToUpdate: String, artifactNameToUpdate: String, newState: DownloadState) { - _pushes.value = _pushes.value.map { push -> - push.copy( - jobs = push.jobs.map { job -> - if (job.taskId == taskIdToUpdate) { - job.copy( - artifacts = job.artifacts.map { artifact -> - if (artifact.name == artifactNameToUpdate) { - artifact.copy(downloadState = newState) - } else { - artifact - } - }, - ) + fun installApk(file: File) { + intentManager.installApk(file) + } + + private fun updateArtifactDownloadState( + taskIdToUpdate: String, + artifactNameToUpdate: String, + newState: DownloadState, + ) { + _pushes.value = _pushes.value.map { push: PushUiModel -> + push.updateTask(taskIdToUpdate, artifactNameToUpdate, newState) + } + } + + private fun PushUiModel.updateTask( + taskId: String, + artifactNameToUpdate: String, + newState: DownloadState, + ): PushUiModel = + copy( + jobs = + jobs.map { job: JobDetailsUiModel -> + if (job.taskId != taskId) { + job + } else { + job.updateArtifact(artifactNameToUpdate, newState) + } + }, + ) + + private fun JobDetailsUiModel.updateArtifact( + artifactNameToUpdate: String, + newState: DownloadState, + ): JobDetailsUiModel = + copy( + artifacts = artifacts.map { + if (it.name != artifactNameToUpdate) { + it } else { - job + it.copy(downloadState = newState) } }, - ) - } - } + ) } diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt new file mode 100644 index 0000000..cf92287 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt @@ -0,0 +1,25 @@ +package org.mozilla.tryfox.data.managers + +import java.io.File + +/** + * A fake implementation of [IntentManager] for use in unit tests. + * This class allows for verifying that the `installApk` method is called. + */ +class FakeIntentManager() : IntentManager { + + /** + * A boolean flag to indicate whether the `installApk` method was called. + */ + var wasInstallApkCalled: Boolean = false + private set + + /** + * Overrides the `installApk` method to set the `wasInstallApkCalled` flag to true. + * + * @param file The file to be "installed". + */ + override fun installApk(file: File) { + wasInstallApkCalled = true + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt new file mode 100644 index 0000000..165d69b --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt @@ -0,0 +1,23 @@ +package org.mozilla.tryfox.data.managers + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import org.mozilla.tryfox.data.UserDataRepository + +/** + * A fake implementation of [UserDataRepository] for testing purposes. + */ +class FakeUserDataRepository : UserDataRepository { + + private val _lastSearchedEmailFlow = MutableStateFlow("") + override val lastSearchedEmailFlow: Flow = _lastSearchedEmailFlow + + override suspend fun saveLastSearchedEmail(email: String) { + _lastSearchedEmailFlow.value = email + } + + // Helper method for tests to clear the stored email if needed + fun clearLastSearchedEmail() { + _lastSearchedEmailFlow.value = "" + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt index 2da365b..37dbf44 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt @@ -32,6 +32,7 @@ import org.mozilla.tryfox.data.IFenixRepository import org.mozilla.tryfox.data.MozillaArchiveRepository import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.FakeCacheManager +import org.mozilla.tryfox.data.managers.FakeIntentManager import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.ParsedNightlyApk import org.mozilla.tryfox.ui.models.AbiUiModel @@ -62,6 +63,8 @@ class HomeViewModelTest { @Mock private lateinit var mockMozillaArchiveRepository: MozillaArchiveRepository + private val intentManager = FakeIntentManager() + @TempDir lateinit var tempCacheDir: File @@ -72,7 +75,12 @@ class HomeViewModelTest { private val testDateRaw = "2023-11-01-01-01-01" private val testAbi = "arm64-v8a" - private fun createTestParsedNightlyApk(appName: String, dateRaw: String?, version: String, abi: String): ParsedNightlyApk { + private fun createTestParsedNightlyApk( + appName: String, + dateRaw: String?, + version: String, + abi: String, + ): ParsedNightlyApk { val fileName = if (appName == testReferenceBrowserAppName) { "target.$abi.apk" } else { @@ -100,7 +108,10 @@ class HomeViewModelTest { ) } - private fun createTestApkUiModel(parsed: ParsedNightlyApk, downloadState: DownloadState = DownloadState.NotDownloaded): ApkUiModel { + private fun createTestApkUiModel( + parsed: ParsedNightlyApk, + downloadState: DownloadState = DownloadState.NotDownloaded, + ): ApkUiModel { val dateFormatted = parsed.rawDateString?.formatApkDateForTest() ?: "" val datePart = if (dateFormatted.length >= 10) dateFormatted.substring(0, 10) else "" @@ -136,7 +147,7 @@ class HomeViewModelTest { fun setUp() = runTest { whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(emptyList())) whenever(mockMozillaArchiveRepository.getFocusNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(emptyList())) - whenever(mockMozillaArchiveRepository.getReferenceBrowserNightlyBuilds()).thenReturn(NetworkResult.Success(emptyList())) // Added + whenever(mockMozillaArchiveRepository.getReferenceBrowserNightlyBuilds()).thenReturn(NetworkResult.Success(emptyList())) fakeCacheManager = FakeCacheManager(tempCacheDir) fakeMozillaPackageManager = FakeMozillaPackageManager() @@ -146,6 +157,7 @@ class HomeViewModelTest { fenixRepository = mockFenixRepository, mozillaPackageManager = fakeMozillaPackageManager, cacheManager = fakeCacheManager, + intentManager = intentManager, ioDispatcher = mainCoroutineRule.testDispatcher, ) viewModel.deviceSupportedAbisForTesting = listOf("arm64-v8a", "x86_64", "armeabi-v7a") @@ -168,7 +180,10 @@ class HomeViewModelTest { @Test fun `initialLoad when no data then homeScreenState is InitialLoading before load completes`() = runTest { - assertTrue(viewModel.homeScreenState.value is HomeScreenState.InitialLoading, "Initial HomeScreenState should be InitialLoading") + assertTrue( + viewModel.homeScreenState.value is HomeScreenState.InitialLoading, + "Initial HomeScreenState should be InitialLoading", + ) } @Test @@ -178,8 +193,8 @@ class HomeViewModelTest { val rbParsed = createTestParsedNightlyApk(testReferenceBrowserAppName, testDateRaw, "latest", "armeabi-v7a") whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(listOf(fenixParsed))) whenever(mockMozillaArchiveRepository.getFocusNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(listOf(focusParsed))) - whenever(mockMozillaArchiveRepository.getReferenceBrowserNightlyBuilds()).thenReturn(NetworkResult.Success(listOf(rbParsed))) // Mocked for RB - fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) // Start with empty cache + whenever(mockMozillaArchiveRepository.getReferenceBrowserNightlyBuilds()).thenReturn(NetworkResult.Success(listOf(rbParsed))) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) viewModel.initialLoad() advanceUntilIdle() @@ -209,9 +224,20 @@ class HomeViewModelTest { @Test fun `initialLoad with multiple builds on same day should only show latest`() = runTest { - val olderFenixParsed = createTestParsedNightlyApk(testFenixAppName, "2023-11-01-01-01-01", "125.0a1", testAbi) - val newerFenixParsed = createTestParsedNightlyApk(testFenixAppName, "2023-11-01-14-01-01", "125.0a1", testAbi) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(listOf(olderFenixParsed, newerFenixParsed))) + val olderFenixParsed = createTestParsedNightlyApk( + testFenixAppName, + "2023-11-01-01-01-01", + "125.0a1", + testAbi, + ) + val newerFenixParsed = createTestParsedNightlyApk( + testFenixAppName, + "2023-11-01-14-01-01", + "125.0a1", + testAbi, + ) + whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())) + .thenReturn(NetworkResult.Success(listOf(olderFenixParsed, newerFenixParsed))) fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) viewModel.initialLoad() @@ -242,14 +268,15 @@ class HomeViewModelTest { } @Test - fun `initialLoad with fenix cache populated should result in IdleNonEmpty cache state from CacheManager`() = runTest { + fun `initialLoad with fenix cache populated should result in IdleNonEmpty from CacheManager`() = runTest { val fenixParsed = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) val fenixApkUi = createTestApkUiModel(fenixParsed) val fenixCacheSubDir = File(tempCacheDir, "${fenixApkUi.appName}/${fenixApkUi.date.take(10)}") fenixCacheSubDir.mkdirs() File(fenixCacheSubDir, fenixApkUi.fileName).createNewFile() - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(listOf(fenixParsed))) + whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())) + .thenReturn(NetworkResult.Success(listOf(fenixParsed))) fakeCacheManager.setCacheState(CacheManagementState.IdleNonEmpty) viewModel.initialLoad() @@ -293,28 +320,47 @@ class HomeViewModelTest { var loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded val fenixSuccessStatePre = loadedState.apps[FENIX]!!.apks as ApksResult.Success assertFalse(fenixSuccessStatePre.apks.isEmpty(), "Fenix APK list should not be empty") - assertTrue(fenixSuccessStatePre.apks.first().downloadState is DownloadState.Downloaded, "Fenix APK download state should be Downloaded") - // RB assertions pre-clear + assertTrue( + fenixSuccessStatePre.apks.first().downloadState is DownloadState.Downloaded, + "Fenix APK download state should be Downloaded", + ) val rbSuccessStatePre = loadedState.apps[REFERENCE_BROWSER]!!.apks as ApksResult.Success assertFalse(rbSuccessStatePre.apks.isEmpty(), "RB APK list should not be empty") - assertTrue(rbSuccessStatePre.apks.first().downloadState is DownloadState.Downloaded, "RB APK download state should be Downloaded") + assertTrue( + rbSuccessStatePre.apks.first().downloadState is DownloadState.Downloaded, + "RB APK download state should be Downloaded", + ) - assertEquals(CacheManagementState.IdleNonEmpty, loadedState.cacheManagementState, "Cache state should be IdleNonEmpty initially") + assertEquals( + CacheManagementState.IdleNonEmpty, + loadedState.cacheManagementState, + "Cache state should be IdleNonEmpty initially", + ) viewModel.clearAppCache() advanceUntilIdle() assertTrue(fakeCacheManager.clearCacheCalled) loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded - assertEquals(CacheManagementState.IdleEmpty, loadedState.cacheManagementState, "Cache state should be IdleEmpty after clear") + assertEquals( + CacheManagementState.IdleEmpty, + loadedState.cacheManagementState, + "Cache state should be IdleEmpty after clear", + ) val fenixStateAfterClear = loadedState.apps[FENIX]!!.apks as ApksResult.Success assertFalse(fenixStateAfterClear.apks.isEmpty(), "Fenix APK list should not be empty after clear") - assertTrue(fenixStateAfterClear.apks.first().downloadState is DownloadState.NotDownloaded, "Fenix APK download state should be NotDownloaded after clear") + assertTrue( + fenixStateAfterClear.apks.first().downloadState is DownloadState.NotDownloaded, + "Fenix APK download state should be NotDownloaded after clear", + ) val rbStateAfterClear = loadedState.apps[REFERENCE_BROWSER]!!.apks as ApksResult.Success assertFalse(rbStateAfterClear.apks.isEmpty(), "RB APK list should not be empty after clear") - assertTrue(rbStateAfterClear.apks.first().downloadState is DownloadState.NotDownloaded, "RB APK download state should be NotDownloaded after clear") + assertTrue( + rbStateAfterClear.apks.first().downloadState is DownloadState.NotDownloaded, + "RB APK download state should be NotDownloaded after clear", + ) } @Test @@ -324,7 +370,8 @@ class HomeViewModelTest { val expectedApkDir = File(tempCacheDir, "${apkToDownload.appName}/${apkToDownload.date.take(10)}") val expectedApkFile = File(expectedApkDir, apkToDownload.fileName) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(listOf(fenixParsed))) + whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())) + .thenReturn(NetworkResult.Success(listOf(fenixParsed))) fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) viewModel.initialLoad() @@ -344,9 +391,6 @@ class HomeViewModelTest { NetworkResult.Success(expectedApkFile) } - var installLambdaCalledWith: File? = null - viewModel.onInstallApk = { installLambdaCalledWith = it } - viewModel.downloadNightlyApk(apkToDownload) advanceUntilIdle() @@ -355,10 +399,13 @@ class HomeViewModelTest { val downloadedApkInfo = fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } assertNotNull(downloadedApkInfo, "Downloaded APK info should not be null") - assertTrue(downloadedApkInfo!!.downloadState is DownloadState.Downloaded, "DownloadState should be Downloaded") + assertTrue( + downloadedApkInfo!!.downloadState is DownloadState.Downloaded, + "DownloadState should be Downloaded", + ) assertEquals(expectedApkFile.path, (downloadedApkInfo.downloadState as DownloadState.Downloaded).file.path) assertTrue(fakeCacheManager.checkCacheStatusCalled) - assertEquals(expectedApkFile, installLambdaCalledWith) + assertTrue(intentManager.wasInstallApkCalled) assertFalse(loadedState.isDownloadingAnyFile, "isDownloadingAnyFile should be false after success") } @@ -370,7 +417,8 @@ class HomeViewModelTest { val expectedApkFile = File(expectedApkDir, apkToDownload.fileName) val downloadErrorMessage = "Download Canceled" - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())).thenReturn(NetworkResult.Success(listOf(fenixParsed))) + whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(anyOrNull())) + .thenReturn(NetworkResult.Success(listOf(fenixParsed))) fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) viewModel.initialLoad() advanceUntilIdle() @@ -390,7 +438,10 @@ class HomeViewModelTest { val failedApkInfo = fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } assertNotNull(failedApkInfo, "Failed APK info should not be null") - assertTrue(failedApkInfo!!.downloadState is DownloadState.DownloadFailed, "DownloadState should be DownloadFailed") + assertTrue( + failedApkInfo!!.downloadState is DownloadState.DownloadFailed, + "DownloadState should be DownloadFailed", + ) assertEquals(downloadErrorMessage, (failedApkInfo.downloadState as DownloadState.DownloadFailed).errorMessage) assertTrue(fakeCacheManager.checkCacheStatusCalled) assertFalse(loadedState.isDownloadingAnyFile, "isDownloadingAnyFile should be false after failure") @@ -400,7 +451,8 @@ class HomeViewModelTest { fun `onDateSelected should update userPickedDate and fetch new builds`() = runTest { val selectedDate = LocalDate(2023, 10, 20) val fenixParsed = createTestParsedNightlyApk(testFenixAppName, "2023-10-20-01-01-01", "124.0a1", testAbi) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(eq(selectedDate))).thenReturn(NetworkResult.Success(listOf(fenixParsed))) + whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(eq(selectedDate))) + .thenReturn(NetworkResult.Success(listOf(fenixParsed))) viewModel.initialLoad() advanceUntilIdle() @@ -421,10 +473,17 @@ class HomeViewModelTest { fun `onClearDate should reset userPickedDate and fetch latest builds`() = runTest { val selectedDate = LocalDate(2023, 10, 20) val initialParsed = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) - val dateSpecificParsed = createTestParsedNightlyApk(testFenixAppName, "2023-10-20-01-01-01", "124.0a1", testAbi) + val dateSpecificParsed = createTestParsedNightlyApk( + testFenixAppName, + "2023-10-20-01-01-01", + "124.0a1", + testAbi, + ) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(null)).thenReturn(NetworkResult.Success(listOf(initialParsed))) - whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(eq(selectedDate))).thenReturn(NetworkResult.Success(listOf(dateSpecificParsed))) + whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(null)) + .thenReturn(NetworkResult.Success(listOf(initialParsed))) + whenever(mockMozillaArchiveRepository.getFenixNightlyBuilds(eq(selectedDate))) + .thenReturn(NetworkResult.Success(listOf(dateSpecificParsed))) viewModel.initialLoad() advanceUntilIdle() 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 5d50877..cf196fa 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 @@ -12,8 +12,9 @@ import org.junit.jupiter.api.io.TempDir import org.mockito.Mock import org.mockito.junit.jupiter.MockitoExtension import org.mozilla.tryfox.data.IFenixRepository -import org.mozilla.tryfox.data.UserDataRepository import org.mozilla.tryfox.data.managers.FakeCacheManager +import org.mozilla.tryfox.data.managers.FakeIntentManager +import org.mozilla.tryfox.data.managers.FakeUserDataRepository import java.io.File @ExperimentalCoroutinesApi @@ -26,8 +27,9 @@ class ProfileViewModelTest { @Mock private lateinit var fenixRepository: IFenixRepository - @Mock - private lateinit var userDataRepository: UserDataRepository + private val userDataRepository = FakeUserDataRepository() + + private val intentManager = FakeIntentManager() @TempDir lateinit var tempCacheDir: File @@ -40,6 +42,8 @@ class ProfileViewModelTest { fenixRepository = fenixRepository, userDataRepository = userDataRepository, cacheManager = cacheManager, + intentManager = intentManager, + authorEmail = null, ) } @@ -51,7 +55,7 @@ class ProfileViewModelTest { @Test fun `updateAuthorEmail should update the authorEmail state`() = runTest { // Given - val viewModel = ProfileViewModel(fenixRepository, userDataRepository, cacheManager) + val viewModel = ProfileViewModel(fenixRepository, userDataRepository, cacheManager, intentManager, null) val newEmail = "test@example.com" viewModel.authorEmail.test {