Skip to content
Open
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
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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"
Expand All @@ -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(
Expand All @@ -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 = "[email protected]"
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))
}
}
112 changes: 47 additions & 65 deletions app/src/main/java/org/mozilla/tryfox/MainActivity.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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?) {
Expand All @@ -53,7 +76,7 @@ class MainActivity : ComponentActivity() {
setContent {
TryFoxTheme {
// Pass the Koin-injected ViewModel
AppNavigation(tryFoxViewModel)
AppNavigation()
}
}
}
Expand All @@ -67,47 +90,29 @@ 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()
[email protected] = localNavController
Log.d("MainActivity", "AppNavigation: NavController instance assigned: $localNavController")

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() },
)
}
Expand All @@ -134,52 +139,29 @@ 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(
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()
}
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) },
)
}
}
Expand Down
15 changes: 13 additions & 2 deletions app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String?>(null)
Expand Down
Loading