diff --git a/README.md b/README.md index 0eb7aed..e24352e 100644 --- a/README.md +++ b/README.md @@ -30,12 +30,13 @@ Install the latest APK from [TryFox Releases](https://github.com/mozilla-mobile/ - `mozilla-central` (displayed as "central") - `mozilla-beta` (displayed as "beta") - `mozilla-release` (displayed as "release") + - `autoland` (displayed as "autoland") - **Search by Revision**: Enter a full revision hash to find associated builds for the selected project. - **Automated CI Data Fetching**: 1. Queries Treeherder API for push details using the selected project and revision. 2. Displays relevant push comment from the revision (often a Bugzilla link). 3. Fetches all jobs associated with the push. - 4. Filters for relevant, signed, non-test build jobs (jobs containing "B" and "s", excluding "t" in their symbols). + 4. Filters for relevant Android APK build jobs, including signed and unsigned builds, while excluding test jobs. 5. For each job, retrieves its artifacts from Taskcluster. - **Job and Artifact Display**: - Lists build jobs with their app icon (e.g., Fenix, Focus), job name, job symbol, and Task ID. @@ -106,7 +107,7 @@ Run Android instrumentation tests with a connected device or running emulator: - **Retrofit** for type-safe HTTP requests to Treeherder and Taskcluster APIs. - **Kotlinx Serialization** for efficient JSON parsing. - **OkHttp** as the underlying HTTP client for Retrofit (with a `HttpLoggingInterceptor` for debugging network traffic). -- **FileProvider** for securely sharing downloaded APKs with the system package installer. +- **PackageInstaller sessions** for installing downloaded APKs directly from TryFox. ## Requirements diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b2d978c..4286643 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -122,6 +122,7 @@ dependencies { // Additional Compose dependencies implementation(libs.androidx.compose.material.icons.extended) implementation(libs.androidx.lifecycle.viewmodel.compose) + implementation(libs.androidx.work.runtime.ktx) // DataStore implementation(libs.androidx.datastore.preferences) diff --git a/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt index 382a0d9..4f6a32c 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/MainActivityDeeplinkTest.kt @@ -82,7 +82,7 @@ class MainActivityDeeplinkTest { } @Test - fun testDeeplink_withAuthorEmail_populatesProfileScreen() { + fun testDeeplink_withAuthorEmail_populatesSearchScreen() { val email = "tthibaud@mozilla.com" val encodedEmail = "tthibaud%40mozilla.com" val deeplinkUri = @@ -118,7 +118,7 @@ class MainActivityDeeplinkTest { } @Test - fun testTryfoxScheme_withAuthorEmail_populatesProfileScreen() { + fun testTryfoxScheme_withAuthorEmail_populatesSearchScreen() { val email = "tthibaud@mozilla.com" val encodedEmail = "tthibaud%40mozilla.com" val deeplinkUri = diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeApkDownloadCoordinator.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeApkDownloadCoordinator.kt new file mode 100644 index 0000000..293d8a7 --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeApkDownloadCoordinator.kt @@ -0,0 +1,17 @@ +package org.mozilla.tryfox.data + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOf +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.PersistedDownloadState + +class FakeApkDownloadCoordinator : ApkDownloadCoordinator { + override val downloads = MutableStateFlow>(emptyMap()) + + override fun enqueue(request: ApkDownloadRequest): String = request.uniqueKey + override fun retry(request: ApkDownloadRequest): String = request.uniqueKey + override fun cancel(uniqueKey: String) = Unit + override fun observe(uniqueKey: String): Flow = flowOf(downloads.value[uniqueKey]) +} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeCacheManager.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeCacheManager.kt index bc35dbe..9e7bf43 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeCacheManager.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeCacheManager.kt @@ -9,12 +9,15 @@ import java.io.File class FakeCacheManager : CacheManager { private val _cacheState = MutableStateFlow(CacheManagementState.IdleEmpty) override val cacheState: StateFlow = _cacheState + private val _cacheSizeBytes = MutableStateFlow(0L) + override val cacheSizeBytes: StateFlow = _cacheSizeBytes override suspend fun clearCache() { _cacheState.value = CacheManagementState.IdleEmpty + _cacheSizeBytes.value = 0L } - override fun checkCacheStatus() { + override suspend fun checkCacheStatus() { // No-op for now } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt index 0ed5d71..239575e 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt @@ -16,7 +16,7 @@ class FakeDownloadFileRepository( override suspend fun downloadFile( downloadUrl: String, outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, + onProgress: suspend (bytesDownloaded: Long, totalBytes: Long) -> Unit, ): NetworkResult { downloadFileCalled = true diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt index d7887ff..e07ed6d 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeIntentManager.kt @@ -1,38 +1,14 @@ 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 { var wasUninstallApkCalled: Boolean = false private set - /** - * 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 - } - override fun uninstallApk(packageName: String) { wasUninstallApkCalled = true } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt index 22cc462..35f29a9 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt @@ -2,8 +2,12 @@ package org.mozilla.tryfox.data import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import org.mozilla.tryfox.data.SearchHistory +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.lan.LanReceiveIdentity +import org.mozilla.tryfox.model.HomeScreenLayout /** * A fake implementation of [org.mozilla.tryfox.data.repositories.UserDataRepository] for testing purposes. @@ -12,19 +16,37 @@ class FakeUserDataRepository : UserDataRepository { private val _lastSearchedEmailFlow = MutableStateFlow("") override val lastSearchedEmailFlow: Flow = _lastSearchedEmailFlow + private val _searchHistoryFlow = MutableStateFlow>(emptyList()) + override val searchHistoryFlow: Flow> = _searchHistoryFlow private val _lanReceiveIdentityFlow = MutableStateFlow(null) override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow + private val _homeScreenLayoutFlow = MutableStateFlow(HomeScreenLayout.OneCardPerApp) + override val homeScreenLayoutFlow: Flow = _homeScreenLayoutFlow override suspend fun saveLastSearchedEmail(email: String) { - _lastSearchedEmailFlow.value = email + recordSearch("try", email) + } + + override suspend fun recordSearch(project: String, query: String, searchedAt: Long) { + val queryType = if ('@' in query) SearchHistoryQueryType.EMAIL else SearchHistoryQueryType.REVISION + _searchHistoryFlow.value = SearchHistory.record( + _searchHistoryFlow.value, + SearchHistoryEntry(project, query, queryType, searchedAt), + ) + _lastSearchedEmailFlow.value = SearchHistory.latestEmail(_searchHistoryFlow.value) } override suspend fun saveLanReceiveIdentity(identity: LanReceiveIdentity) { _lanReceiveIdentityFlow.value = identity } + override suspend fun saveHomeScreenLayout(layout: HomeScreenLayout) { + _homeScreenLayoutFlow.value = layout + } + // Helper method for tests to clear the stored email if needed fun clearLastSearchedEmail() { _lastSearchedEmailFlow.value = "" + _searchHistoryFlow.value = emptyList() } } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ProgressButtonTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ProgressButtonTest.kt new file mode 100644 index 0000000..d05c567 --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ProgressButtonTest.kt @@ -0,0 +1,449 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.activity.ComponentActivity +import androidx.compose.runtime.mutableStateOf +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.SemanticsProperties +import androidx.compose.ui.test.SemanticsNodeInteraction +import androidx.compose.ui.test.assertCountEquals +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.hasClickAction +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onAllNodesWithText +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Rule +import org.junit.Test +import kotlin.math.abs + +class ProgressButtonTest { + + @get:Rule + val composeRule = createAndroidComposeRule() + + @Test + fun showsIdleTextWhenNotLoading() { + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = false, + onClick = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithText("Create file").assertIsDisplayed() + composeRule.onAllNodesWithText("Loading").assertCountEquals(0) + } + + @Test + fun showsLoadingTextWhenLoading() { + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = true, + onClick = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithText("Loading").assertIsDisplayed() + composeRule.onAllNodesWithText("Create file").assertCountEquals(0) + } + + @Test + fun buttonDisabledWhenNotEnabled() { + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = false, + enabled = false, + onClick = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithText("Create file").assertIsNotEnabled() + } + + @Test + fun buttonClickInvokesCallback() { + var clicks = 0 + + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = false, + onClick = { clicks++ }, + modifier = Modifier, + ) + } + + composeRule.onNode(hasClickAction()).performClick() + composeRule.runOnIdle { + assertEquals(1, clicks) + } + } + + @Test + fun determinateProgressStillShowsLoadingText() { + composeRule.setContent { + ProgressButton( + text = "Create file", + loadingText = "Loading", + isLoading = true, + progress = 0.5f, + onClick = {}, + modifier = Modifier, + ) + } + + composeRule.onNodeWithText("Loading").assertIsDisplayed() + } + + @Test + fun indicatorColorTransitionsOnlyAfterCompletionSweep() { + composeRule.mainClock.autoAdvance = false + val loadingState = mutableStateOf(true) + val indicatorColor = Color.Blue + val trackEndColor = Color.Green + + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = loadingState.value, + indicatorColor = indicatorColor, + trackEndColor = trackEndColor, + completionSweepMillis = 200f, + endingAnimation = EndingAnimation.None, + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + assertColorClose(indicatorColor, button.indicatorColor()) + + composeRule.mainClock.advanceTimeBy(100L) + composeRule.waitForIdle() + assertColorClose(indicatorColor, button.indicatorColor(), tolerance = 0.1f) + + composeRule.runOnIdle { loadingState.value = false } + + composeRule.mainClock.advanceTimeBy(200L) + composeRule.waitForIdle() + assertColorClose(indicatorColor, button.indicatorColor(), tolerance = 0.3f) + + composeRule.mainClock.advanceTimeBy(400L) + composeRule.waitForIdle() + assertColorClose(trackEndColor, button.indicatorColor(), tolerance = 0.1f) + + composeRule.mainClock.autoAdvance = true + } + + @Test + fun borderAlphaHoldsDuringConstantDelayThenFades() { + composeRule.mainClock.autoAdvance = false + val loadingState = mutableStateOf(true) + + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = loadingState.value, + indicatorColor = Color.Blue, + trackEndColor = Color.Red, + completionSweepMillis = 200f, + endingAnimation = EndingAnimation.Constant(duration = 400f), + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + + composeRule.runOnIdle { loadingState.value = false } + + composeRule.mainClock.advanceTimeBy(200L) + composeRule.waitForIdle() + assertFloatEquals(1f, button.borderAlpha()) + + composeRule.mainClock.advanceTimeBy(400L) + composeRule.waitForIdle() + assertFloatEquals(1f, button.borderAlpha()) + + var finalAlpha = 1f + repeat(15) { + composeRule.mainClock.advanceTimeBy(100L) + composeRule.waitForIdle() + finalAlpha = button.borderAlpha() + if (finalAlpha <= 0.1f) return@repeat + } + assertFloatEquals(0f, finalAlpha, tolerance = 0.1f) + + composeRule.mainClock.autoAdvance = true + } + + @Test + fun noneEndingShowsIdleTextImmediatelyAfterCompletionSweep() { + composeRule.mainClock.autoAdvance = false + val loadingState = mutableStateOf(true) + + composeRule.setContent { + ProgressButton( + text = "Install", + loadingText = "Download", + isLoading = loadingState.value, + progress = 0.6f, + indicatorColor = Color.Green, + trackEndColor = Color.Green, + completionSweepMillis = 200f, + endingAnimation = EndingAnimation.None, + onClick = {}, + ) + } + + composeRule.onNodeWithText("Download").assertIsDisplayed() + composeRule.runOnIdle { loadingState.value = false } + composeRule.mainClock.advanceTimeBy(250L) + composeRule.waitForIdle() + + composeRule.onNodeWithText("Install").assertIsDisplayed() + composeRule.onAllNodesWithText("Download").assertCountEquals(0) + composeRule.mainClock.autoAdvance = true + } + + @Test + fun progressFractionMatchesDeterminateProgress() { + val progressState = 0.65f + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = true, + progress = progressState, + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + assertFloatEquals(progressState, button.progressFraction()) + assertEquals( + ProgressBarRangeInfo(progressState, 0f..1f), + button.progressRangeInfo(), + ) + } + + @Test + fun determinateProgressLargeIncreaseAnimatesSmoothly() { + composeRule.mainClock.autoAdvance = false + val progressState = mutableStateOf(0.1f) + + composeRule.setContent { + ProgressButton( + text = "Upload", + isLoading = true, + progress = progressState.value, + onClick = {}, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.mainClock.advanceTimeBy(2_000L) + composeRule.waitForIdle() + assertFloatEquals(0.1f, button.progressFraction()) + + composeRule.runOnIdle { progressState.value = 0.9f } + composeRule.mainClock.advanceTimeBy(100L) + composeRule.waitForIdle() + val intermediateProgress = button.progressFraction() + assertTrue( + "Expected an intermediate value, but was $intermediateProgress", + intermediateProgress > 0.1f && intermediateProgress < 0.9f, + ) + + composeRule.mainClock.advanceTimeBy(2_000L) + composeRule.waitForIdle() + assertFloatEquals(0.9f, button.progressFraction()) + composeRule.mainClock.autoAdvance = true + } + + @Test + fun rotatingDeterminateProgressMovesWhileItsArcGrows() { + composeRule.mainClock.autoAdvance = false + val progressState = mutableStateOf(0.2f) + val loadingState = mutableStateOf(true) + + composeRule.setContent { + ProgressButton( + text = "Upload", + isLoading = loadingState.value, + progress = progressState.value, + determinateProgressAnimation = DeterminateProgressAnimation.Rotating, + onClick = {}, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.mainClock.advanceTimeBy(2_000L) + composeRule.waitForIdle() + val initialStart = button.progressStartFraction() + assertFloatEquals(0.2f, button.progressFraction()) + + composeRule.runOnIdle { progressState.value = 0.8f } + composeRule.mainClock.advanceTimeBy(200L) + composeRule.waitForIdle() + + assertTrue("Expected the arc to rotate", button.progressStartFraction() != initialStart) + assertTrue("Expected the arc to grow smoothly", button.progressFraction() in 0.2f..0.8f) + + val startBeforeCompletion = button.progressStartFraction() + composeRule.runOnIdle { loadingState.value = false } + composeRule.mainClock.advanceTimeBy(100L) + composeRule.waitForIdle() + assertTrue( + "Expected the leading edge to keep rotating while completing", + button.progressStartFraction() != startBeforeCompletion, + ) + composeRule.mainClock.autoAdvance = true + } + + @Test + fun indeterminateProgressSemanticsExposed() { + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = true, + progress = null, + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + assertEquals(ProgressBarRangeInfo.Indeterminate, button.progressRangeInfo()) + } + + @Test + fun pulseEndingProducesBeatPattern() { + composeRule.mainClock.autoAdvance = false + val loadingState = mutableStateOf(true) + + composeRule.setContent { + ProgressButton( + text = "Upload", + loadingText = "Working…", + isLoading = loadingState.value, + indicatorColor = Color.Blue, + trackEndColor = Color.Magenta, + completionSweepMillis = 200f, + endingAnimation = EndingAnimation.Pulse( + beatDuration = 200f, + beats = 2, + delayBetweenBeats = 100f, + ), + onClick = {}, + modifier = Modifier, + ) + } + + val button = composeRule.onNodeWithTag(PROGRESS_BUTTON_TAG) + composeRule.waitForIdle() + + composeRule.runOnIdle { loadingState.value = false } + + composeRule.mainClock.advanceTimeBy(200L) // sweep + composeRule.mainClock.advanceTimeBy(300L) // indicator color animation + composeRule.mainClock.advanceTimeBy(100L) // initial delay + + var dropDetected = false + repeat(6) { + composeRule.mainClock.advanceTimeBy(50L) + composeRule.waitForIdle() + val alpha = button.borderAlpha() + if (alpha < 0.92f) { + dropDetected = true + return@repeat + } + } + assertTrue("Expected drop during pulse", dropDetected) + + var restoredToFull = false + repeat(4) { + composeRule.mainClock.advanceTimeBy(50L) + composeRule.waitForIdle() + val alpha = button.borderAlpha() + if (alpha >= 0.95f) { + restoredToFull = true + return@repeat + } + } + assertTrue("Expected alpha to return to full", restoredToFull) + + composeRule.mainClock.advanceTimeBy(100L) // delay between beats + + var secondDropDetected = false + repeat(6) { + composeRule.mainClock.advanceTimeBy(50L) + composeRule.waitForIdle() + val alpha = button.borderAlpha() + if (alpha < 0.92f) { + secondDropDetected = true + return@repeat + } + } + assertTrue("Expected drop on second beat", secondDropDetected) + + composeRule.mainClock.autoAdvance = true + } + + private fun SemanticsNodeInteraction.indicatorColor(): Color = + fetchSemanticsNode().config[IndicatorColorKey] + + private fun SemanticsNodeInteraction.borderAlpha(): Float = + fetchSemanticsNode().config[BorderAlphaKey] + + private fun SemanticsNodeInteraction.progressFraction(): Float = + fetchSemanticsNode().config[ProgressFractionKey] + + private fun SemanticsNodeInteraction.progressStartFraction(): Float = + fetchSemanticsNode().config[ProgressStartFractionKey] + + private fun SemanticsNodeInteraction.progressRangeInfo(): ProgressBarRangeInfo = + fetchSemanticsNode().config[SemanticsProperties.ProgressBarRangeInfo] + + private fun assertColorClose(expected: Color, actual: Color, tolerance: Float = 0.1f) { + val distance = abs(expected.red - actual.red) + + abs(expected.green - actual.green) + + abs(expected.blue - actual.blue) + + abs(expected.alpha - actual.alpha) + assertTrue( + "Color mismatch. Expected $expected, got $actual (distance $distance)", + distance <= tolerance, + ) + } + + private fun assertFloatEquals(expected: Float, actual: Float, tolerance: Float = 0.001f) { + assertTrue( + "Expected $expected, got $actual (tolerance $tolerance)", + abs(expected - actual) <= tolerance, + ) + } +} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt index e1f8fbb..74c4d0b 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HistoryScreenTest.kt @@ -13,11 +13,12 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith import org.mozilla.tryfox.R +import org.mozilla.tryfox.data.FakeApkDownloadCoordinator import org.mozilla.tryfox.data.FakeCacheManager -import org.mozilla.tryfox.data.FakeDownloadFileRepository import org.mozilla.tryfox.data.FakeHistoryRepository -import org.mozilla.tryfox.data.FakeIntentManager import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry +import org.mozilla.tryfox.data.repositories.EmptyInstalledTryBuildRepository +import org.mozilla.tryfox.install.ApkInstallCoordinator import org.mozilla.tryfox.ui.theme.TryFoxTheme @RunWith(AndroidJUnit4::class) @@ -34,9 +35,9 @@ class HistoryScreenTest { } val historyViewModel = HistoryViewModel( historyRepository = historyRepository, - downloadFileRepository = FakeDownloadFileRepository(), + downloadCoordinator = FakeApkDownloadCoordinator(), cacheManager = FakeCacheManager(), - intentManager = FakeIntentManager(), + installCoordinator = installCoordinator(), ) var selectedProject: String? = null var selectedRevision: String? = null @@ -68,9 +69,9 @@ class HistoryScreenTest { } val historyViewModel = HistoryViewModel( historyRepository = historyRepository, - downloadFileRepository = FakeDownloadFileRepository(), + downloadCoordinator = FakeApkDownloadCoordinator(), cacheManager = FakeCacheManager(), - intentManager = FakeIntentManager(), + installCoordinator = installCoordinator(), ) composeTestRule.setContent { @@ -119,4 +120,9 @@ class HistoryScreenTest { historyRecordedTimestamp = 123L, lastInstallerLaunchTimestamp = 123L, ) + + private fun installCoordinator() = ApkInstallCoordinator( + InstrumentationRegistry.getInstrumentation().targetContext, + EmptyInstalledTryBuildRepository, + ) } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt new file mode 100644 index 0000000..245b21d --- /dev/null +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt @@ -0,0 +1,143 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import kotlinx.datetime.LocalDate +import org.junit.Assert.assertEquals +import org.junit.Rule +import org.junit.Test +import org.junit.runner.RunWith +import org.mozilla.tryfox.data.InstalledTryBuild +import org.mozilla.tryfox.ui.models.AbiUiModel +import org.mozilla.tryfox.ui.models.ApkUiModel +import org.mozilla.tryfox.ui.models.ApksResult +import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.ui.theme.TryFoxTheme +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE +import java.io.File + +@RunWith(AndroidJUnit4::class) +class HomeAppCardTest { + + @get:Rule + val composeTestRule = createComposeRule() + + @Test + fun matchingFenixDebugTryBuildShowsCommitAndOpensItsRevision() { + val build = InstalledTryBuild( + packageName = FENIX_DEBUG_PACKAGE, + project = "mozilla-central", + revision = "abcdef123456", + commitMessage = "Bug 123456: Fix the Fenix Debug build\n\nIgnored commit description", + versionName = "145.0a1", + versionCode = 42L, + ) + var openedProject: String? = null + var openedRevision: String? = null + + composeTestRule.setContent { + TryFoxTheme { + HomeAppCard( + card = HomeAppCardUiModel( + family = HomeAppFamily.Fenix, + selectedAppName = FENIX_DEBUG, + appsByName = mapOf( + FENIX_DEBUG to AppUiModel( + name = FENIX_DEBUG, + packageName = FENIX_DEBUG_PACKAGE, + installedVersion = "145.0a1", + installedVersionCode = 42L, + installedDate = "2026-08-05", + installedTryBuild = build, + apks = ApksResult.Loading, + ), + ), + ), + installStates = emptyMap(), + onFlavorSelected = {}, + onDownloadClick = {}, + onInstallClick = {}, + onOpenInstalledApp = {}, + onOpenTryBuild = { project, revision -> + openedProject = project + openedRevision = revision + }, + onDateSelected = { _, _ -> }, + dateValidator = { true }, + onReleaseVersionSelected = { _, _ -> }, + onBuildSelected = { _, _ -> }, + onDismissBuildPicker = {}, + ) + } + } + + composeTestRule.onNodeWithText("Bug 123456: Fix the Fenix Debug build").assertIsDisplayed() + composeTestRule.onNodeWithTag("home_try_build_revision").performClick() + + assertEquals("mozilla-central", openedProject) + assertEquals("abcdef123456", openedRevision) + } + + @Test + fun nightlyCalendarUsesStoredBuildDateInsteadOfRelativeCardLabel() { + val buildDate = LocalDate(2024, 12, 31) + var selectedDate: LocalDate? = null + + composeTestRule.setContent { + TryFoxTheme { + HomeAppCard( + card = HomeAppCardUiModel( + family = HomeAppFamily.Fenix, + selectedAppName = FENIX, + appsByName = mapOf( + FENIX to AppUiModel( + name = FENIX, + packageName = "org.mozilla.fenix", + installedVersion = null, + installedDate = null, + apks = ApksResult.Success( + listOf( + ApkUiModel( + originalString = "", + date = "Today 09:17", + buildDate = buildDate, + appName = FENIX, + version = "145.0a1", + abi = AbiUiModel("arm64-v8a", true), + url = "https://example.invalid/fenix.apk", + fileName = "fenix.apk", + uniqueKey = "fenix/2024-12-31-09-17-32/fenix.apk", + apkDir = File("/tmp/fenix"), + ), + ), + ), + ), + ), + ), + installStates = emptyMap(), + onFlavorSelected = {}, + onDownloadClick = {}, + onInstallClick = {}, + onOpenInstalledApp = {}, + onOpenTryBuild = { _, _ -> }, + onDateSelected = { _, date -> selectedDate = date }, + dateValidator = { true }, + onReleaseVersionSelected = { _, _ -> }, + onBuildSelected = { _, _ -> }, + onDismissBuildPicker = {}, + ) + } + } + + composeTestRule.onNodeWithTag("home_nightly_date_$FENIX").performClick() + composeTestRule.onNodeWithText("OK").performClick() + + assertEquals(buildDate, selectedDate) + } +} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt index d9bd701..06f7b1f 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeScreenTest.kt @@ -19,45 +19,10 @@ class HomeScreenTest { val composeTestRule = createAndroidComposeRule() @Test - fun homeScreen_showsFenixNightlyCard() { - // --- Fenix (Nightly) Card --- - val fenixTitleTag = "app_title_text_fenix" - - // Assert Fenix text (found by tag) is displayed - composeTestRule.onNodeWithTag(fenixTitleTag, useUnmergedTree = true).assertIsDisplayed() - } - - @Test - fun homeScreen_showsFenixBetaCard() { - // --- Fenix Beta Card --- - val betaTitleTag = "app_title_text_fenix-beta" - - // Assert Beta text (found by tag) is displayed - composeTestRule.onNodeWithTag(betaTitleTag, useUnmergedTree = true).assertIsDisplayed() - } - - @Test - fun homeScreen_showsFenixReleaseCard() { - // --- Fenix Release Card --- - val releaseTitleTag = "app_title_text_fenix-release" - - // Assert Release text (found by tag) is displayed - composeTestRule.onNodeWithTag(releaseTitleTag, useUnmergedTree = true).assertIsDisplayed() - } - - @Test - fun homeScreen_showsAllThreeFenixVariants() { - // --- Fenix (Nightly) Card --- - val fenixTitleTag = "app_title_text_fenix" - composeTestRule.onNodeWithTag(fenixTitleTag, useUnmergedTree = true).assertIsDisplayed() - - // --- Fenix Beta Card --- - val betaTitleTag = "app_title_text_fenix-beta" - composeTestRule.onNodeWithTag(betaTitleTag, useUnmergedTree = true).assertIsDisplayed() - - // --- Fenix Release Card --- - val releaseTitleTag = "app_title_text_fenix-release" - composeTestRule.onNodeWithTag(releaseTitleTag, useUnmergedTree = true).assertIsDisplayed() + fun homeScreen_showsThreeGroupedAppCards() { + composeTestRule.onNodeWithTag("home_app_card_fenix", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithTag("home_app_card_focus", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithTag("home_app_card_referencebrowser", useUnmergedTree = true).assertIsDisplayed() } @Test @@ -66,4 +31,15 @@ class HomeScreenTest { composeTestRule.onNodeWithText("Scan QR code").assertIsDisplayed() } + + @Test + fun homeScreen_settingsButtonNavigatesToSettingsScreen() { + composeTestRule.onNodeWithContentDescription("Settings").performClick() + + composeTestRule.onNodeWithText("Settings").assertIsDisplayed() + composeTestRule.onNodeWithText("Cache").assertIsDisplayed() + composeTestRule.onNodeWithText("Home screen layout").assertIsDisplayed() + composeTestRule.onNodeWithText("One card per app").assertIsDisplayed() + composeTestRule.onNodeWithText("One card per flavor of each app").assertIsDisplayed() + } } 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 deleted file mode 100644 index 228dd82..0000000 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/ProfileScreenTest.kt +++ /dev/null @@ -1,128 +0,0 @@ -package org.mozilla.tryfox.ui.screens - -import androidx.compose.ui.semantics.SemanticsProperties -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.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.FakeDownloadFileRepository -import org.mozilla.tryfox.data.FakeHistoryRepository -import org.mozilla.tryfox.data.FakeIntentManager -import org.mozilla.tryfox.data.FakeTreeherderRepository -import org.mozilla.tryfox.data.FakeUserDataRepository -import org.mozilla.tryfox.data.managers.CacheManager -import org.mozilla.tryfox.data.repositories.UserDataRepository - -@RunWith(AndroidJUnit4::class) -class ProfileScreenTest { - - @get:Rule - val composeTestRule = createComposeRule() - - private val fenixRepository = FakeTreeherderRepository() - private val downloadFileRepository = FakeDownloadFileRepository() - private val userDataRepository: UserDataRepository = FakeUserDataRepository() - private val cacheManager: CacheManager = FakeCacheManager() - private val intentManager = FakeIntentManager() - private val historyRepository = FakeHistoryRepository() - private val emailInputTag = "profile_email_input" - private val emailClearButtonTag = "profile_email_clear_button" - private val searchButtonTag = "profile_search_button" - - private val downloadButtonInitialTag = "action_button_download_initial" - private val downloadButtonLoadingTag = "action_button_downloading" - private val downloadButtonInstallTag = "action_button_install_ready" - - private val longTimeoutMillis = 1_000L - - @Test - fun searchPushesAndCheckDownloadAndInstallStates() { - val profileViewModel = ProfileViewModel( - fenixRepository = fenixRepository, - downloadFileRepository = downloadFileRepository, - userDataRepository = userDataRepository, - cacheManager = cacheManager, - intentManager = intentManager, - historyRepository = historyRepository, - authorEmail = null, - ) - - composeTestRule.setContent { - ProfileScreen( - profileViewModel = profileViewModel, - onNavigateUp = { }, - ) - } - - val emailFieldNode = composeTestRule.onNodeWithTag(emailInputTag).fetchSemanticsNode() - if (emailFieldNode.config[SemanticsProperties.EditableText].text.isNotEmpty()) { - composeTestRule.onNodeWithTag(emailClearButtonTag).performClick() - } - - composeTestRule.onNodeWithTag(emailInputTag).performTextInput("example@mozilla.com") - composeTestRule.onNodeWithTag(searchButtonTag).performClick() - - composeTestRule.waitUntil("Wait for at least one download button", longTimeoutMillis) { - composeTestRule.onAllNodesWithTag(downloadButtonInitialTag, useUnmergedTree = true) - .fetchSemanticsNodes().isNotEmpty() - } - - val timestampChips = composeTestRule - .onAllNodesWithTag("push_timestamp_chip_fakerevision123", useUnmergedTree = true) - .fetchSemanticsNodes() - assertTrue( - "Expected a push timestamp chip to be rendered for Try push entry", - timestampChips.isNotEmpty(), - ) - - composeTestRule.onNodeWithTag(downloadButtonInitialTag, useUnmergedTree = true) - .performClick() - - composeTestRule.waitUntil("Download button enters loading state", longTimeoutMillis) { - composeTestRule.onAllNodesWithTag(downloadButtonLoadingTag, useUnmergedTree = true) - .fetchSemanticsNodes().isNotEmpty() - } - - composeTestRule.waitUntil("Download button enters install state", longTimeoutMillis) { - composeTestRule.onAllNodesWithTag(downloadButtonInstallTag, useUnmergedTree = true) - .fetchSemanticsNodes().isNotEmpty() - } - - assertTrue( - "APK file should have been captured by onInstallApk callback", - intentManager.wasInstallApkCalled, - ) - } - - @Test - fun test_profileScreen_displays_initial_authorEmail_in_searchField() { - val initialEmail = "initial@example.com" - val profileViewModelWithEmail = ProfileViewModel( - fenixRepository = fenixRepository, - downloadFileRepository = downloadFileRepository, - userDataRepository = userDataRepository, - cacheManager = cacheManager, - intentManager = intentManager, - historyRepository = historyRepository, - authorEmail = initialEmail, - ) - - composeTestRule.setContent { - ProfileScreen( - profileViewModel = profileViewModelWithEmail, - onNavigateUp = { }, - ) - } - - composeTestRule.onNodeWithTag(emailInputTag).assert(hasText(initialEmail)) - } -} diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt similarity index 55% rename from app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt rename to app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt index 5feba89..3d03221 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreenTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/SearchScreenTest.kt @@ -1,5 +1,8 @@ package org.mozilla.tryfox.ui.screens +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.compose.ui.test.assertCountEquals import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule @@ -7,30 +10,36 @@ import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onAllNodesWithText import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.coroutines.delay +import org.junit.Assert.assertEquals import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -import org.mozilla.tryfox.TryFoxViewModel import org.mozilla.tryfox.data.Artifact import org.mozilla.tryfox.data.ArtifactsResponse +import org.mozilla.tryfox.data.FakeApkDownloadCoordinator import org.mozilla.tryfox.data.FakeCacheManager -import org.mozilla.tryfox.data.FakeDownloadFileRepository import org.mozilla.tryfox.data.FakeHistoryRepository -import org.mozilla.tryfox.data.FakeIntentManager +import org.mozilla.tryfox.data.FakeUserDataRepository import org.mozilla.tryfox.data.JobDetails import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.RevisionDetail import org.mozilla.tryfox.data.RevisionMeta import org.mozilla.tryfox.data.RevisionResult +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.TreeherderJobsResponse import org.mozilla.tryfox.data.TreeherderRevisionResponse +import org.mozilla.tryfox.data.repositories.EmptyInstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository +import org.mozilla.tryfox.install.ApkInstallCoordinator import org.mozilla.tryfox.ui.theme.TryFoxTheme @RunWith(AndroidJUnit4::class) -class TreeherderApksScreenTest { +class SearchScreenTest { @get:Rule val composeTestRule = createComposeRule() @@ -38,24 +47,17 @@ class TreeherderApksScreenTest { @Test fun treeherderScreen_showsLoaderUntilSearchCompletes_thenDisplaysResults() { val targetJobName = "signing-apk-focus-nightly" - val viewModel = TryFoxViewModel( + val targetDisplayName = "Focus nightly" + val viewModel = searchViewModel( fenixRepository = DelayedTreeherderRepository(targetJobName = targetJobName), - downloadFileRepository = FakeDownloadFileRepository(), - cacheManager = FakeCacheManager(), - intentManager = FakeIntentManager(), - historyRepository = FakeHistoryRepository(), - project = "mozilla-central", - revision = "ed209aa2136b241686ff20489c5cb622348e2ecf", - supportedAbis = listOf("arm64-v8a"), - infoLogger = { _, _ -> 0 }, ) composeTestRule.setContent { TryFoxTheme { - TryFoxMainScreen( - tryFoxViewModel = viewModel, - deepLinkProject = null, - deepLinkRevision = null, + SearchScreen( + searchViewModel = viewModel, + deepLinkProject = "mozilla-central", + deepLinkQuery = "ed209aa2136b241686ff20489c5cb622348e2ecf", onNavigateUp = {}, ) } @@ -68,22 +70,90 @@ class TreeherderApksScreenTest { composeTestRule.onNodeWithTag(TREEHERDER_LOADING_STATE_TAG, useUnmergedTree = true) .assertIsDisplayed() - composeTestRule.onAllNodesWithText(targetJobName, substring = false, useUnmergedTree = true) + composeTestRule.onAllNodesWithText(targetDisplayName, substring = false, useUnmergedTree = true) .assertCountEquals(0) composeTestRule.waitUntil(timeoutMillis = 5_000) { - composeTestRule.onAllNodesWithTag(TREEHERDER_RESULTS_HEADER_TAG, useUnmergedTree = true) + composeTestRule.onAllNodesWithTag("revision_search_push_ed209aa2136b241686ff20489c5cb622348e2ecf", useUnmergedTree = true) .fetchSemanticsNodes().isNotEmpty() } composeTestRule.onAllNodesWithTag(TREEHERDER_LOADING_STATE_TAG, useUnmergedTree = true) .assertCountEquals(0) - composeTestRule.onNodeWithTag(TREEHERDER_RESULTS_HEADER_TAG, useUnmergedTree = true) + composeTestRule.onNodeWithText(targetDisplayName, substring = false, useUnmergedTree = true) .assertIsDisplayed() - composeTestRule.onNodeWithText(targetJobName, substring = false, useUnmergedTree = true) + composeTestRule.onNodeWithTag("revision_search_push_ed209aa2136b241686ff20489c5cb622348e2ecf", useUnmergedTree = true) .assertIsDisplayed() } + @Test + fun searchHistory_filtersSuggestions_andSelectsAnEntry() { + val emailEntry = SearchHistoryEntry( + project = "mozilla-central", + query = "person@mozilla.org", + queryType = SearchHistoryQueryType.EMAIL, + searchedAt = 2L, + ) + var query by mutableStateOf("") + var selectedEntry: SearchHistoryEntry? = null + + composeTestRule.setContent { + TryFoxTheme { + SearchSection( + selectedProject = "try", + onProjectSelected = {}, + revision = query, + onRevisionChange = { query = it }, + onSearchClick = {}, + isLoading = false, + searchHistory = listOf( + emailEntry, + SearchHistoryEntry("try", "abc123", SearchHistoryQueryType.REVISION, 1L), + ), + onHistoryItemSelected = { selectedEntry = it }, + ) + } + } + + composeTestRule.onNodeWithTag(TREEHERDER_SEARCH_HISTORY_TAG).assertIsDisplayed() + composeTestRule.onNodeWithText("Recent searches").assertIsDisplayed() + composeTestRule.onNodeWithText("central").assertIsDisplayed() + + composeTestRule.onNodeWithText("person@mozilla.org").performClick() + composeTestRule.runOnIdle { + assertEquals(emailEntry, selectedEntry) + } + + composeTestRule.onNodeWithTag("profile_email_input").performTextInput("no-match") + composeTestRule.onNodeWithTag("profile_email_clear_button").assertIsDisplayed() + composeTestRule.onAllNodesWithTag(TREEHERDER_SEARCH_HISTORY_TAG).assertCountEquals(0) + } + + @Test + fun selectingHistoryEntry_hidesHistoryBeforeShowingSearchResults() { + val viewModel = searchViewModel( + fenixRepository = DelayedTreeherderRepository(targetJobName = "signing-apk-focus-nightly"), + ) + + composeTestRule.setContent { + TryFoxTheme { + SearchScreen( + searchViewModel = viewModel, + deepLinkProject = null, + deepLinkQuery = null, + onNavigateUp = {}, + searchHistory = listOf( + SearchHistoryEntry("try", "abc123", SearchHistoryQueryType.REVISION, 1L), + ), + ) + } + } + + composeTestRule.onNodeWithTag(TREEHERDER_SEARCH_HISTORY_TAG).assertIsDisplayed() + composeTestRule.onNodeWithTag("treeherder_search_history_0").performClick() + composeTestRule.onAllNodesWithTag(TREEHERDER_SEARCH_HISTORY_TAG).assertCountEquals(0) + } + private class DelayedTreeherderRepository( private val targetJobName: String, ) : TreeherderRepository { @@ -169,4 +239,19 @@ class TreeherderApksScreenTest { ) } } + + private fun installCoordinator() = ApkInstallCoordinator( + androidx.test.platform.app.InstrumentationRegistry.getInstrumentation().targetContext, + EmptyInstalledTryBuildRepository, + ) + + private fun searchViewModel(fenixRepository: TreeherderRepository) = SearchViewModel( + fenixRepository = fenixRepository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeApkDownloadCoordinator(), + installCoordinator = installCoordinator(), + authorEmail = null, + ) } diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9d7bbe1..6a19a87 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,9 +4,13 @@ + + @@ -14,8 +18,10 @@ + + @@ -62,23 +68,9 @@ - - - - - - - - + android:name=".install.InstallResultReceiver" + android:exported="false" /> + + diff --git a/app/src/main/java/org/mozilla/tryfox/AppDeepLinkParser.kt b/app/src/main/java/org/mozilla/tryfox/AppDeepLinkParser.kt index 4ea6906..afc1ca2 100644 --- a/app/src/main/java/org/mozilla/tryfox/AppDeepLinkParser.kt +++ b/app/src/main/java/org/mozilla/tryfox/AppDeepLinkParser.kt @@ -12,6 +12,7 @@ sealed interface AppDeepLinkDestination { data class Profile( val email: String, + val project: String = "try", ) : AppDeepLinkDestination } @@ -61,7 +62,10 @@ object AppDeepLinkParser { val author = parameters["author"]?.takeIf { it.isNotBlank() } if (author != null) { - return AppDeepLinkDestination.Profile(email = author) + return AppDeepLinkDestination.Profile( + email = author, + project = parameters["repo"]?.takeIf { it.isNotBlank() } ?: DEFAULT_PROJECT, + ) } return null diff --git a/app/src/main/java/org/mozilla/tryfox/AppDeepLinkRouteMapper.kt b/app/src/main/java/org/mozilla/tryfox/AppDeepLinkRouteMapper.kt index c08dc0f..12ca168 100644 --- a/app/src/main/java/org/mozilla/tryfox/AppDeepLinkRouteMapper.kt +++ b/app/src/main/java/org/mozilla/tryfox/AppDeepLinkRouteMapper.kt @@ -6,12 +6,12 @@ object AppDeepLinkRouteMapper { is AppDeepLinkDestination.TreeherderSearch -> { AppRoutes.createTreeherderSearchRoute( project = destination.project, - revision = destination.revision, + query = destination.revision, ) } is AppDeepLinkDestination.Profile -> { - AppRoutes.createProfileByEmailRoute(destination.email) + AppRoutes.createTreeherderSearchRoute(project = destination.project, query = destination.email) } null -> null diff --git a/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt b/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt index 5e6d69a..51fe2d5 100644 --- a/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt +++ b/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt @@ -5,20 +5,15 @@ import java.net.URLEncoder object AppRoutes { const val HOME = "home" const val HISTORY = "history" + const val SETTINGS = "settings" const val RECEIVE_FROM_DESKTOP = "receive_from_desktop" const val RECEIVE_MESSAGE_HISTORY = "receive_message_history" const val QR_SCANNER = "qr_scanner" const val TREEHERDER_SEARCH = "treeherder_search" - const val TREEHERDER_SEARCH_WITH_ARGS = "treeherder_search/{project}/{revision}" - const val PROFILE = "profile" - const val PROFILE_BY_EMAIL = "profile_by_email?email={email}" + const val TREEHERDER_SEARCH_WITH_ARGS = "treeherder_search/{project}/{query}" - fun createTreeherderSearchRoute(project: String, revision: String): String { - return "treeherder_search/${encode(project)}/${encode(revision)}" - } - - fun createProfileByEmailRoute(email: String): String { - return "profile_by_email?email=${encode(email)}" + fun createTreeherderSearchRoute(project: String, query: String): String { + return "treeherder_search/${encode(project)}/${encode(query)}" } private fun encode(value: String): String { diff --git a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt index 3e38b8b..91ebbc4 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -6,27 +6,43 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.core.tween +import androidx.compose.animation.slideInHorizontally +import androidx.compose.animation.slideOutHorizontally +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.lifecycleScope import androidx.navigation.NavHostController import androidx.navigation.NavType import androidx.navigation.compose.NavHost import androidx.navigation.compose.composable import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument +import kotlinx.coroutines.launch +import org.koin.android.ext.android.inject import org.koin.androidx.compose.koinViewModel import org.koin.core.parameter.parametersOf import org.mozilla.tryfox.EXTRA_RECEIVE_FROM_DESKTOP_START_REQUESTED +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.screens.HistoryScreen import org.mozilla.tryfox.ui.screens.HomeScreen -import org.mozilla.tryfox.ui.screens.ProfileScreen import org.mozilla.tryfox.ui.screens.QrCodeScannerScreen import org.mozilla.tryfox.ui.screens.ReceiveFromDesktopScreen import org.mozilla.tryfox.ui.screens.ReceiveMessageHistoryScreen -import org.mozilla.tryfox.ui.screens.TryFoxMainScreen +import org.mozilla.tryfox.ui.screens.SearchHistoryViewModel +import org.mozilla.tryfox.ui.screens.SearchScreen +import org.mozilla.tryfox.ui.screens.SearchViewModel +import org.mozilla.tryfox.ui.screens.SettingsScreen import org.mozilla.tryfox.ui.theme.TryFoxTheme /** @@ -44,6 +60,8 @@ sealed class NavScreen(val route: String) { */ data object History : NavScreen(AppRoutes.HISTORY) + data object Settings : NavScreen(AppRoutes.SETTINGS) + data object ReceiveFromDesktop : NavScreen(AppRoutes.RECEIVE_FROM_DESKTOP) data object ReceiveMessageHistory : NavScreen(AppRoutes.RECEIVE_MESSAGE_HISTORY) @@ -59,7 +77,7 @@ sealed class NavScreen(val route: String) { data object TreeherderSearch : NavScreen(AppRoutes.TREEHERDER_SEARCH) /** - * Represents the Treeherder search screen with project and revision arguments. + * Represents the Treeherder search screen with project and query arguments. */ data object TreeherderSearchWithArgs : NavScreen(AppRoutes.TREEHERDER_SEARCH_WITH_ARGS) { /** @@ -70,21 +88,9 @@ sealed class NavScreen(val route: String) { */ fun createRoute(project: String, revision: String) = AppRoutes.createTreeherderSearchRoute( project = project, - revision = revision, + query = revision, ) } - - /** - * Represents the Profile screen. - */ - data object Profile : NavScreen(AppRoutes.PROFILE) - - /** - * Represents the Profile screen filtered by email. - */ - data object ProfileByEmail : NavScreen(AppRoutes.PROFILE_BY_EMAIL) { - fun createRoute(email: String) = AppRoutes.createProfileByEmailRoute(email) - } } /** @@ -92,11 +98,30 @@ sealed class NavScreen(val route: String) { * This activity sets up the navigation host and handles deep links. */ class MainActivity : ComponentActivity() { + private val installCoordinator: ApkInstallCoordinator by inject() private lateinit var navController: NavHostController private var receiveFromDesktopStartRequested by mutableStateOf(false) + private var pendingUninstallOperationId: String? = null + private val uninstallLauncher = registerForActivityResult(ActivityResultContracts.StartActivityForResult()) { result -> + pendingUninstallOperationId?.let { operationId -> + installCoordinator.onUninstallResult(operationId, result.resultCode == RESULT_OK) + } + pendingUninstallOperationId = null + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + lifecycleScope.launch { + installCoordinator.uninstallRequests.collect { request -> + pendingUninstallOperationId = request.operationId + uninstallLauncher.launch( + Intent(Intent.ACTION_UNINSTALL_PACKAGE).apply { + data = Uri.fromParts("package", request.packageName, null) + putExtra(Intent.EXTRA_RETURN_RESULT, true) + }, + ) + } + } enableEdgeToEdge() setContent { TryFoxTheme { @@ -121,22 +146,76 @@ class MainActivity : ComponentActivity() { @Suppress("LongMethod") @Composable fun AppNavigation() { + val appSearchHistoryViewModel: SearchHistoryViewModel = koinViewModel() + val installStates by installCoordinator.states.collectAsState() + val installConflict = installStates.entries.firstOrNull { (_, state) -> state is InstallState.Conflict } val localNavController = rememberNavController() this@MainActivity.navController = localNavController + installConflict?.let { (artifactKey, state) -> + val conflict = state as InstallState.Conflict + AlertDialog( + onDismissRequest = { installCoordinator.cancelConflict(artifactKey) }, + title = { Text(stringResource(id = R.string.install_conflict_title)) }, + text = { Text(stringResource(R.string.install_conflict_message, conflict.packageName)) }, + confirmButton = { + Button(onClick = { installCoordinator.confirmUninstallAndRetry(artifactKey) }) { + Text(stringResource(id = R.string.install_conflict_confirm)) + } + }, + dismissButton = { + Button(onClick = { installCoordinator.cancelConflict(artifactKey) }) { + Text(stringResource(id = R.string.install_conflict_cancel)) + } + }, + ) + } + LaunchedEffect(localNavController) { routeDeepLink(intent) } - NavHost(navController = localNavController, startDestination = NavScreen.Home.route) { + NavHost( + navController = localNavController, + startDestination = NavScreen.Home.route, + enterTransition = { + slideInHorizontally( + animationSpec = tween(SCREEN_TRANSITION_DURATION_MS), + initialOffsetX = { fullWidth -> fullWidth }, + ) + }, + exitTransition = { + slideOutHorizontally( + animationSpec = tween(SCREEN_TRANSITION_DURATION_MS), + targetOffsetX = { fullWidth -> -fullWidth }, + ) + }, + popEnterTransition = { + slideInHorizontally( + animationSpec = tween(SCREEN_TRANSITION_DURATION_MS), + initialOffsetX = { fullWidth -> -fullWidth }, + ) + }, + popExitTransition = { + slideOutHorizontally( + animationSpec = tween(SCREEN_TRANSITION_DURATION_MS), + targetOffsetX = { fullWidth -> fullWidth }, + ) + }, + ) { composable(NavScreen.Home.route) { // Inject HomeViewModel using Koin in Composable HomeScreen( - onNavigateToTreeherder = { localNavController.navigate(NavScreen.TreeherderSearch.route) }, - onNavigateToProfile = { localNavController.navigate(NavScreen.Profile.route) }, + onNavigateToSearch = { localNavController.navigate(NavScreen.TreeherderSearch.route) }, onNavigateToQrScanner = { localNavController.navigate(NavScreen.QrScanner.route) }, onNavigateToReceiveFromDesktop = { localNavController.navigate(NavScreen.ReceiveFromDesktop.route) }, onNavigateToHistory = { localNavController.navigate(NavScreen.History.route) }, + onNavigateToSettings = { localNavController.navigate(NavScreen.Settings.route) }, + onNavigateToTryBuild = { project, revision -> + localNavController.navigate( + NavScreen.TreeherderSearchWithArgs.createRoute(project, revision), + ) + }, homeViewModel = koinViewModel(), ) } @@ -151,12 +230,16 @@ class MainActivity : ComponentActivity() { historyViewModel = koinViewModel(), ) } + composable(NavScreen.Settings.route) { + SettingsScreen( + onNavigateUp = { localNavController.popBackStack() }, + settingsViewModel = koinViewModel(), + ) + } composable(NavScreen.QrScanner.route) { QrCodeScannerScreen( onNavigateUp = { localNavController.popBackStack() }, - onQrCodeScanned = { rawValue -> - routeDeepLink(rawValue, popQrScanner = true) - }, + onQrCodeScanned = { rawValue -> routeDeepLink(rawValue, popQrScanner = true) }, ) } composable(NavScreen.ReceiveFromDesktop.route) { @@ -165,6 +248,11 @@ class MainActivity : ComponentActivity() { onNavigateToMessageHistory = { localNavController.navigate(NavScreen.ReceiveMessageHistory.route) }, + onNavigateToTreeherderRevision = { project, revision -> + localNavController.navigate( + NavScreen.TreeherderSearchWithArgs.createRoute(project, revision), + ) + }, receiveFromDesktopViewModel = koinViewModel(), startReceiverOnEnter = receiveFromDesktopStartRequested, onStartReceiverOnEnterConsumed = { @@ -180,45 +268,32 @@ class MainActivity : ComponentActivity() { ) } composable(NavScreen.TreeherderSearch.route) { + val searchHistory by appSearchHistoryViewModel.searchHistory.collectAsState() // mainActivityViewModel is already injected and passed as a parameter - TryFoxMainScreen( - tryFoxViewModel = koinViewModel(), + SearchScreen( + searchViewModel = koinViewModel { parametersOf("", "try") }, deepLinkProject = null, - deepLinkRevision = null, + deepLinkQuery = null, onNavigateUp = { localNavController.popBackStack() }, + searchHistory = searchHistory, ) } composable( route = NavScreen.TreeherderSearchWithArgs.route, arguments = listOf( navArgument("project") { type = NavType.StringType }, - navArgument("revision") { type = NavType.StringType }, + navArgument("query") { type = NavType.StringType }, ), ) { backStackEntry -> val project = backStackEntry.arguments?.getString("project") - val revision = backStackEntry.arguments?.getString("revision") - TryFoxMainScreen( - tryFoxViewModel = koinViewModel { parametersOf(project, revision) }, + val query = backStackEntry.arguments?.getString("query")?.let(Uri::decode).orEmpty() + val searchHistory by appSearchHistoryViewModel.searchHistory.collectAsState() + SearchScreen( + searchViewModel = koinViewModel { parametersOf("", project) }, deepLinkProject = project, - deepLinkRevision = revision, - onNavigateUp = { localNavController.popBackStack() }, - ) - } - composable(NavScreen.Profile.route) { - ProfileScreen( - onNavigateUp = { localNavController.popBackStack() }, - profileViewModel = koinViewModel(), - ) - } - composable( - route = NavScreen.ProfileByEmail.route, - arguments = listOf(navArgument("email") { type = NavType.StringType }), - ) { backStackEntry -> - val email = backStackEntry.arguments?.getString("email")?.let(Uri::decode) - - ProfileScreen( + deepLinkQuery = query, onNavigateUp = { localNavController.popBackStack() }, - profileViewModel = koinViewModel { parametersOf(email) }, + searchHistory = searchHistory, ) } } @@ -241,8 +316,28 @@ class MainActivity : ComponentActivity() { } private fun routeDeepLink(rawValue: String?, popQrScanner: Boolean): Boolean { - val route = AppDeepLinkRouteMapper.routeFor(rawValue) ?: return false + when (val destination = AppDeepLinkParser.parse(rawValue)) { + is AppDeepLinkDestination.TreeherderSearch -> { + navigateToDeepLinkRoute( + AppRoutes.createTreeherderSearchRoute(destination.project, destination.revision), + popQrScanner, + ) + return true + } + is AppDeepLinkDestination.Profile -> { + navigateToDeepLinkRoute( + AppRoutes.createTreeherderSearchRoute(destination.project, destination.email), + popQrScanner, + ) + return true + } + + null -> return false + } + } + + private fun navigateToDeepLinkRoute(route: String, popQrScanner: Boolean) { navController.navigate(route) { launchSingleTop = true if (popQrScanner) { @@ -251,6 +346,9 @@ class MainActivity : ComponentActivity() { } } } - return true + } + + private companion object { + private const val SCREEN_TRANSITION_DURATION_MS = 200 } } diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index 184870e..8eae768 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -25,16 +25,21 @@ import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.CacheManager -import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.data.repositories.DownloadFileRepository import org.mozilla.tryfox.data.repositories.HistoryRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.TryBuildProvenance import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ArtifactUiModel import org.mozilla.tryfox.ui.models.JobDetailsUiModel +import org.mozilla.tryfox.ui.screens.needsPrecedingRealCommit +import org.mozilla.tryfox.ui.screens.selectPreferredPushComment import org.mozilla.tryfox.util.TREEHERDER import java.io.File @@ -51,7 +56,7 @@ class TryFoxViewModel( private val fenixRepository: TreeherderRepository, private val downloadFileRepository: DownloadFileRepository, private val cacheManager: CacheManager, - private val intentManager: IntentManager, + private val installCoordinator: ApkInstallCoordinator, private val historyRepository: HistoryRepository, project: String?, revision: String?, @@ -122,14 +127,15 @@ class TryFoxViewModel( var selectedJobs by mutableStateOf>(emptyList()) private set - val isLoadingJobArtifacts = mutableStateMapOf() + var successfulSearch by mutableStateOf(null) + private set - var onInstallApk: ((File) -> Unit)? = null + val isLoadingJobArtifacts = mutableStateMapOf() private val deviceSupportedAbis: List by lazy { supportedAbis } init { - if (revision != null) { + if (!revision.isNullOrBlank() && '@' !in revision) { searchJobsAndArtifacts() } cacheManager.cacheState.onEach { state -> @@ -180,8 +186,10 @@ class TryFoxViewModel( } fun checkCacheStatus() { - cacheManager.checkCacheStatus() - refreshArtifactDownloadStatesFromCache() + viewModelScope.launch(ioDispatcher) { + cacheManager.checkCacheStatus() + refreshArtifactDownloadStatesFromCache() + } } fun clearAppCache() { @@ -191,6 +199,7 @@ class TryFoxViewModel( } fun searchJobsAndArtifacts() { + successfulSearch = null if (revision.isBlank()) { errorMessage = "Please enter a revision to search." return @@ -211,27 +220,30 @@ class TryFoxViewModel( when (val revisionResult = fenixRepository.getPushByRevision(selectedProject, revision)) { is NetworkResult.Success -> { val pushData = revisionResult.data - var foundComment: String? = null val firstPushResult = pushData.results.firstOrNull() if (firstPushResult != null) { - for (revDetail in firstPushResult.revisions) { - if (revDetail.comments.startsWith("Bug ")) { - foundComment = revDetail.comments - break + val precedingPushRevisions = if (needsPrecedingRealCommit(firstPushResult.revisions)) { + when (val authorPushes = fenixRepository.getPushesByAuthor(selectedProject, firstPushResult.author)) { + is NetworkResult.Success -> { + val pushIndex = authorPushes.data.results.indexOfFirst { it.id == firstPushResult.id } + authorPushes.data.results + .take(pushIndex.coerceAtLeast(0)) + .asReversed() + .map { it.revisions } + } + is NetworkResult.Error -> emptyList() } + } else { + emptyList() } - if (foundComment == null) { - foundComment = firstPushResult.revisions.firstOrNull()?.comments ?: "No comment" - } + relevantPushComment = selectPreferredPushComment(firstPushResult.revisions, precedingPushRevisions) relevantPushAuthor = firstPushResult.author relevantPushTimestamp = firstPushResult.pushTimestamp } else { relevantPushAuthor = null relevantPushTimestamp = null } - relevantPushComment = foundComment - if (pushData.results.isEmpty()) { errorMessage = "No push found for project: $selectedProject, revision: $revision" isLoading = false @@ -392,6 +404,14 @@ class TryFoxViewModel( } selectedJobs = selectedJobs.filter { it.artifacts.isNotEmpty() } + if (selectedJobs.isNotEmpty()) { + successfulSearch = SearchHistoryEntry( + project = selectedProject, + query = revision, + queryType = SearchHistoryQueryType.REVISION, + searchedAt = currentTimeMillisProvider(), + ) + } infoLogger( TAG, "searchJobsAndArtifacts: finished in ${elapsedRealtimeProvider() - loadStartMs} ms with ${selectedJobs.size} job(s) shown", @@ -551,14 +571,6 @@ class TryFoxViewModel( is NetworkResult.Success -> { updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.Downloaded(result.data)) cacheManager.checkCacheStatus() // Update cache status via CacheManager - onInstallApk?.let { installCallback -> - try { - updateInstallTimestamp(job = findJob(taskId), artifact = artifactUiModel) - } catch (_: Exception) { - // History is best-effort; never block installation. - } - installCallback(result.data) - } } is NetworkResult.Error -> { updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.DownloadFailed(result.message)) @@ -615,11 +627,7 @@ class TryFoxViewModel( } fun installApk(file: File) { - val downloadedArtifact = findDownloadedArtifact(file) - if (downloadedArtifact == null) { - intentManager.installApk(file) - return - } + val downloadedArtifact = findDownloadedArtifact(file) ?: return viewModelScope.launch { try { @@ -630,7 +638,15 @@ class TryFoxViewModel( } catch (_: Exception) { // History is best-effort; never block installation. } - intentManager.installApk(file) + installCoordinator.install( + downloadedArtifact.artifact.uniqueKey, + file, + TryBuildProvenance( + project = selectedProject, + revision = revision, + commitMessage = relevantPushComment ?: "No comment", + ), + ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt b/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt new file mode 100644 index 0000000..9e682d4 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/UnifiedSearchViewModel.kt @@ -0,0 +1,9 @@ +package org.mozilla.tryfox + +import org.mozilla.tryfox.ui.screens.SearchViewModel + +/** + * Compatibility name for integrations which adopted the initial unified-search proposal. + * Both names resolve to the one shared state holder. + */ +typealias UnifiedSearchViewModel = SearchViewModel diff --git a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt index c3f28dc..c4386e4 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt @@ -16,11 +16,15 @@ import kotlinx.coroutines.flow.callbackFlow import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_BETA_PACKAGE +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE import org.mozilla.tryfox.util.FENIX_NIGHTLY import org.mozilla.tryfox.util.FENIX_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FENIX_RELEASE_PACKAGE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_BETA_PACKAGE import org.mozilla.tryfox.util.FOCUS_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.FOCUS_RELEASE_PACKAGE @@ -79,7 +83,9 @@ class DefaultMozillaPackageManager(private val context: Context) : MozillaPackag FENIX_NIGHTLY_PACKAGE to FENIX_NIGHTLY, FENIX_RELEASE_PACKAGE to FENIX_RELEASE, FENIX_BETA_PACKAGE to FENIX_BETA, + FENIX_DEBUG_PACKAGE to FENIX_DEBUG, FOCUS_NIGHTLY_PACKAGE to FOCUS, + FOCUS_BETA_PACKAGE to FOCUS_BETA, FOCUS_RELEASE_PACKAGE to FOCUS_RELEASE, REFERENCE_BROWSER_PACKAGE to REFERENCE_BROWSER, TRYFOX_PACKAGE to TRYFOX, @@ -94,12 +100,18 @@ class DefaultMozillaPackageManager(private val context: Context) : MozillaPackag override val fenixBeta: AppState get() = getAppState(FENIX_BETA_PACKAGE) + override val fenixDebug: AppState + get() = getAppState(FENIX_DEBUG_PACKAGE) + override val focus: AppState get() = getAppState(FOCUS_NIGHTLY_PACKAGE) override val focusRelease: AppState get() = getAppState(FOCUS_RELEASE_PACKAGE) + override val focusBeta: AppState + get() = getAppState(FOCUS_BETA_PACKAGE) + override val referenceBrowser: AppState get() = getAppState(REFERENCE_BROWSER_PACKAGE) diff --git a/app/src/main/java/org/mozilla/tryfox/data/InstalledTryBuild.kt b/app/src/main/java/org/mozilla/tryfox/data/InstalledTryBuild.kt new file mode 100644 index 0000000..dbc8450 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/InstalledTryBuild.kt @@ -0,0 +1,14 @@ +package org.mozilla.tryfox.data + +import kotlinx.serialization.Serializable + +/** Provenance for the Try build currently known to be installed on the device. */ +@Serializable +data class InstalledTryBuild( + val packageName: String, + val project: String, + val revision: String, + val commitMessage: String, + val versionName: String?, + val versionCode: Long, +) diff --git a/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt b/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt index 1b73f5f..0dbb4cc 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/MozillaPackageManager.kt @@ -23,6 +23,9 @@ interface MozillaPackageManager { */ val fenixBeta: AppState + /** The [AppState] for Firefox Debug. */ + val fenixDebug: AppState + /** * The [AppState] for Focus Nightly. */ @@ -33,6 +36,9 @@ interface MozillaPackageManager { */ val focusRelease: AppState + /** The [AppState] for Focus Beta. */ + val focusBeta: AppState + /** * The [AppState] for Reference Browser. */ diff --git a/app/src/main/java/org/mozilla/tryfox/data/SearchHistoryEntry.kt b/app/src/main/java/org/mozilla/tryfox/data/SearchHistoryEntry.kt new file mode 100644 index 0000000..3f5abdb --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/SearchHistoryEntry.kt @@ -0,0 +1,55 @@ +package org.mozilla.tryfox.data + +import kotlinx.serialization.Serializable + +@Serializable +data class SearchHistoryEntry( + val project: String, + val query: String, + val queryType: SearchHistoryQueryType, + val searchedAt: Long, +) + +@Serializable +enum class SearchHistoryQueryType { + EMAIL, + REVISION, +} + +object SearchHistory { + const val MAX_ENTRIES = 15 + + fun record(entries: List, entry: SearchHistoryEntry): List { + val normalizedEntry = entry.copy(project = entry.project.trim(), query = entry.query.trim()) + return listOf(normalizedEntry) + entries.filterNot { existing -> + existing.project.equals(normalizedEntry.project, ignoreCase = true) && + existing.query.equals(normalizedEntry.query, ignoreCase = true) + }.take(MAX_ENTRIES - 1) + } + + fun displayOrder(entries: List): List { + val newestEmail = entries + .asSequence() + .filter { it.queryType == SearchHistoryQueryType.EMAIL } + .maxByOrNull(SearchHistoryEntry::searchedAt) + return listOfNotNull(newestEmail) + entries + .filterNot { it == newestEmail } + .sortedByDescending(SearchHistoryEntry::searchedAt) + } + + fun latestEmail(entries: List): String = + entries.filter { it.queryType == SearchHistoryQueryType.EMAIL } + .maxByOrNull(SearchHistoryEntry::searchedAt) + ?.query + .orEmpty() + + fun legacyEmailEntry(email: String): SearchHistoryEntry? = + email.trim().takeIf(String::isNotBlank)?.let { + SearchHistoryEntry( + project = "try", + query = it, + queryType = SearchHistoryQueryType.EMAIL, + searchedAt = 0L, + ) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/managers/CacheManager.kt b/app/src/main/java/org/mozilla/tryfox/data/managers/CacheManager.kt index 13e0076..03cfeba 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/managers/CacheManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/CacheManager.kt @@ -6,7 +6,8 @@ import java.io.File interface CacheManager { val cacheState: StateFlow + val cacheSizeBytes: StateFlow suspend fun clearCache() - fun checkCacheStatus() + suspend fun checkCacheStatus() fun getCacheDir(appName: String): File } diff --git a/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt b/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt index 8480df6..8d56e68 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/DefaultCacheManager.kt @@ -26,6 +26,8 @@ class DefaultCacheManager( private val _cacheState = MutableStateFlow(CacheManagementState.IdleEmpty) override val cacheState: StateFlow = _cacheState.asStateFlow() + private val _cacheSizeBytes = MutableStateFlow(0L) + override val cacheSizeBytes: StateFlow = _cacheSizeBytes.asStateFlow() init { migrateLegacyCache(legacyCacheDir) @@ -35,42 +37,39 @@ class DefaultCacheManager( return File(cacheDir, appName) } - private fun isAppCachePopulated(appName: String): Boolean { - val appSpecificCacheDir = getCacheDir(appName) - if (!appSpecificCacheDir.exists() || !appSpecificCacheDir.isDirectory) return false - appSpecificCacheDir.listFiles()?.forEach { item -> - if (item.isFile) return true - if (item.isDirectory) { - if (item.listFiles()?.any { it.isFile } == true) { - return true - } - } + override suspend fun checkCacheStatus() { + val cacheSnapshot = withContext(ioDispatcher) { determineCacheSnapshot() } + val newCacheState = if (cacheSnapshot.hasFiles) { + CacheManagementState.IdleNonEmpty + } else { + CacheManagementState.IdleEmpty } - return false - } - - private fun determineCacheState(): CacheManagementState { - val cacheIsNotEmpty = MANAGED_CACHE_NAMES.any { isAppCachePopulated(it) } - return if (cacheIsNotEmpty) CacheManagementState.IdleNonEmpty else CacheManagementState.IdleEmpty - } - - override fun checkCacheStatus() { - val newCacheState = determineCacheState() + _cacheSizeBytes.value = cacheSnapshot.sizeBytes _cacheState.value = newCacheState logcat(TAG) { "Cache status checked. Current state: $newCacheState" } } + private fun determineCacheSnapshot(): CacheSnapshot = + cacheDir.takeIf { it.isDirectory } + ?.walkTopDown() + ?.fold(CacheSnapshot()) { snapshot, file -> + if (file.isFile) { + snapshot.copy(sizeBytes = snapshot.sizeBytes + file.length(), hasFiles = true) + } else { + snapshot + } + } + ?: CacheSnapshot() + override suspend fun clearCache() { _cacheState.value = CacheManagementState.Clearing try { withContext(ioDispatcher) { cacheDir.listFiles()?.forEach { - if (it.isDirectory) { - logcat(LogPriority.DEBUG, TAG) { - "Deleting cache directory path=${it.absolutePath}" - } - it.deleteRecursively() + logcat(LogPriority.DEBUG, TAG) { + "Deleting cache entry path=${it.absolutePath}" } + it.deleteRecursively() } } logcat(LogPriority.DEBUG, TAG) { "Cache cleared successfully." } @@ -129,4 +128,9 @@ class DefaultCacheManager( private const val TAG = "DefaultCacheManager" private val MANAGED_CACHE_NAMES = listOf(FENIX, FOCUS, FOCUS_RELEASE, REFERENCE_BROWSER, TREEHERDER, TRYFOX) } + + private data class CacheSnapshot( + val sizeBytes: Long = 0L, + val hasFiles: Boolean = false, + ) } 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 index 85028d5..279802a 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/managers/IntentManager.kt @@ -3,58 +3,17 @@ 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 androidx.core.net.toUri -import org.mozilla.tryfox.BuildConfig -import java.io.File -/** - * Interface for managing intents related to APK installation. - */ +/** Interface for managing intents related to installed applications. */ interface IntentManager { - /** - * Initiates the installation of an APK file. - * - * @param file The APK file to install. - */ - fun installApk(file: File) fun uninstallApk(packageName: String) } -/** - * Default implementation of [IntentManager] that handles APK installation using a [FileProvider]. - * - * @param context The application context. - */ +/** Default implementation of [IntentManager]. */ 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) - } - } - override fun uninstallApk(packageName: String) { val intent = Intent(Intent.ACTION_DELETE).apply { data = "package:$packageName".toUri() diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepository.kt index c9e1752..651a428 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepository.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.data.repositories +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -17,7 +18,11 @@ class DefaultDownloadFileRepository( private val downloadApiService: DownloadApiService, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, ) : DownloadFileRepository { - override suspend fun downloadFile(downloadUrl: String, outputFile: File, onProgress: (Long, Long) -> Unit): NetworkResult { + override suspend fun downloadFile( + downloadUrl: String, + outputFile: File, + onProgress: suspend (Long, Long) -> Unit, + ): NetworkResult { return withContext(ioDispatcher) { val partialFile = File(outputFile.parentFile, "${outputFile.name}.part") var backupFile = File(outputFile.parentFile, "${outputFile.name}.bak") @@ -118,6 +123,12 @@ class DefaultDownloadFileRepository( "totalBytes=$totalBytes, exists=${outputFile.exists()}, length=${outputFile.length()}" } NetworkResult.Success(outputFile) + } catch (e: CancellationException) { + partialFile.delete() + if (backupFile.exists() && !outputFile.exists()) { + backupFile.renameTo(outputFile) + } + throw e } catch (e: Exception) { partialFile.delete() if (backupFile.exists() && !outputFile.exists()) { diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt index 8d810b5..4fbc7f4 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt @@ -15,6 +15,7 @@ import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_RELEASE import retrofit2.HttpException @@ -71,11 +72,19 @@ class DefaultMozillaArchiveRepository( } override suspend fun getFocusReleaseBuilds(): NetworkResult> { + return getFocusReleaseBuilds(ReleaseType.Release) + } + + override suspend fun getFocusBetaBuilds(): NetworkResult> { + return getFocusReleaseBuilds(ReleaseType.Beta) + } + + private suspend fun getFocusReleaseBuilds(releaseType: ReleaseType): NetworkResult> { return try { val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FOCUS_BASE_URL) val latestReleaseVersion = mozillaArchiveHtmlParser.parseFenixReleasesFromHtml( releasesHtml, - ReleaseType.Release, + releaseType, ) if (latestReleaseVersion.isEmpty()) { @@ -86,8 +95,8 @@ class DefaultMozillaArchiveRepository( version = latestReleaseVersion, archiveBaseUrl = RELEASES_FOCUS_BASE_URL, archiveAppName = FOCUS, - resultAppName = FOCUS_RELEASE, - releaseType = ReleaseType.Release, + resultAppName = if (releaseType == ReleaseType.Release) FOCUS_RELEASE else FOCUS_BETA, + releaseType = releaseType, ) } catch (e: Exception) { NetworkResult.Error("Failed to fetch or parse Focus releases: ${e.message}", e) @@ -110,9 +119,17 @@ class DefaultMozillaArchiveRepository( } override suspend fun getFocusReleaseVersions(): NetworkResult> { + return getFocusReleaseVersions(ReleaseType.Release) + } + + override suspend fun getFocusBetaVersions(): NetworkResult> { + return getFocusReleaseVersions(ReleaseType.Beta) + } + + private suspend fun getFocusReleaseVersions(releaseType: ReleaseType): NetworkResult> { return try { val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FOCUS_BASE_URL) - val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, ReleaseType.Release) + val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, releaseType) if (releaseVersions.isEmpty()) { return NetworkResult.Error("No Focus release versions found", null) @@ -146,6 +163,14 @@ class DefaultMozillaArchiveRepository( } override suspend fun getFocusReleaseBuildsForVersion(version: String): NetworkResult> { + return getFocusReleaseBuildsForVersion(version, ReleaseType.Release) + } + + override suspend fun getFocusBetaBuildsForVersion(version: String): NetworkResult> { + return getFocusReleaseBuildsForVersion(version, ReleaseType.Beta) + } + + private suspend fun getFocusReleaseBuildsForVersion(version: String, releaseType: ReleaseType): NetworkResult> { return try { if (version.isEmpty()) { return NetworkResult.Error("No version provided", null) @@ -155,8 +180,8 @@ class DefaultMozillaArchiveRepository( version = version, archiveBaseUrl = RELEASES_FOCUS_BASE_URL, archiveAppName = FOCUS, - resultAppName = FOCUS_RELEASE, - releaseType = ReleaseType.Release, + resultAppName = if (releaseType == ReleaseType.Release) FOCUS_RELEASE else FOCUS_BETA, + releaseType = releaseType, ) } catch (e: Exception) { NetworkResult.Error("Failed to fetch Focus release $version: ${e.message}", e) diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt index 7477cb0..faea7c9 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.data.repositories +import kotlinx.coroutines.CancellationException import org.mozilla.tryfox.data.ArtifactsResponse import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.TreeherderJobsResponse @@ -17,6 +18,9 @@ class DefaultTreeherderRepository( private suspend fun safeApiCall(apiCall: suspend () -> T): NetworkResult { return try { NetworkResult.Success(apiCall.invoke()) + } catch (e: CancellationException) { + // Cancellation is lifecycle control flow, not a failed Treeherder request. + throw e } catch (e: Exception) { NetworkResult.Error(e.message ?: "Unknown error", e) } @@ -30,7 +34,35 @@ class DefaultTreeherderRepository( } override suspend fun getPushesByAuthor(author: String): NetworkResult { - return safeApiCall { treeherderApiService.getPushByAuthor(author = author) } + return getPushesByAuthor(project = "try", author = author) + } + + override suspend fun getPushesByAuthor( + project: String, + author: String, + count: Int, + offset: Int, + pushTimestampLte: Long?, + ): NetworkResult { + return safeApiCall { + treeherderApiService.getPushByAuthor( + project = project, + author = author, + count = count, + offset = offset, + pushTimestampLte = pushTimestampLte, + ) + } + } + + override suspend fun getRecentPushes( + project: String, + count: Int, + offset: Int, + ): NetworkResult { + return safeApiCall { + treeherderApiService.getRecentPushes(project = project, count = count, offset = offset) + } } override suspend fun getJobsForPush(pushId: Int): NetworkResult { diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt index 8c42bde..a8329fd 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt @@ -8,7 +8,14 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.map +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.mozilla.tryfox.data.SearchHistory +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.lan.LanReceiveIdentity +import org.mozilla.tryfox.model.HomeScreenLayout /** * A repository that stores the last searched email in a DataStore. @@ -19,15 +26,25 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos private object PreferenceKeys { val USER_EMAIL = stringPreferencesKey("user_email_preference_key") + val SEARCH_HISTORY = stringPreferencesKey("search_history_preference_key") val LAN_DEVICE_ID = stringPreferencesKey("lan_device_id") val LAN_DEVICE_NAME = stringPreferencesKey("lan_device_name") val LAN_SHARED_SECRET = stringPreferencesKey("lan_shared_secret") + val HOME_SCREEN_LAYOUT = stringPreferencesKey("home_screen_layout") } - override val lastSearchedEmailFlow: Flow = appContext.dataStore.data - .map { preferences -> - preferences[PreferenceKeys.USER_EMAIL] ?: "" + override val searchHistoryFlow: Flow> = appContext.dataStore.data.map { preferences -> + val storedHistory = preferences[PreferenceKeys.SEARCH_HISTORY] + ?.let(::decodeSearchHistory) + .orEmpty() + if (storedHistory.isNotEmpty()) { + storedHistory + } else { + listOfNotNull(SearchHistory.legacyEmailEntry(preferences[PreferenceKeys.USER_EMAIL].orEmpty())) } + } + + override val lastSearchedEmailFlow: Flow = searchHistoryFlow.map(SearchHistory::latestEmail) override val lanReceiveIdentityFlow: Flow = appContext.dataStore.data .map { preferences -> @@ -45,9 +62,29 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos } } + override val homeScreenLayoutFlow: Flow = appContext.dataStore.data.map { preferences -> + homeScreenLayoutFromStoredValue(preferences[PreferenceKeys.HOME_SCREEN_LAYOUT]) + } + override suspend fun saveLastSearchedEmail(email: String) { + recordSearch(project = "try", query = email) + } + + override suspend fun recordSearch(project: String, query: String, searchedAt: Long) { + val normalizedQuery = query.trim() + val queryType = if ('@' in normalizedQuery) SearchHistoryQueryType.EMAIL else SearchHistoryQueryType.REVISION + val entry = SearchHistoryEntry(project.trim(), normalizedQuery, queryType, searchedAt) appContext.dataStore.edit { preferences -> - preferences[PreferenceKeys.USER_EMAIL] = email + val existingEntries = preferences[PreferenceKeys.SEARCH_HISTORY] + ?.let(::decodeSearchHistory) + .orEmpty() + .ifEmpty { + listOfNotNull(SearchHistory.legacyEmailEntry(preferences[PreferenceKeys.USER_EMAIL].orEmpty())) + } + preferences[PreferenceKeys.SEARCH_HISTORY] = json.encodeToString(SearchHistory.record(existingEntries, entry)) + if (queryType == SearchHistoryQueryType.EMAIL) { + preferences[PreferenceKeys.USER_EMAIL] = normalizedQuery + } } } @@ -58,4 +95,22 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos preferences[PreferenceKeys.LAN_SHARED_SECRET] = identity.sharedSecret } } + + override suspend fun saveHomeScreenLayout(layout: HomeScreenLayout) { + appContext.dataStore.edit { preferences -> + preferences[PreferenceKeys.HOME_SCREEN_LAYOUT] = layout.name + } + } + + private fun decodeSearchHistory(serializedHistory: String): List = + runCatching { json.decodeFromString>(serializedHistory) }.getOrDefault(emptyList()) + + private companion object { + val json = Json { ignoreUnknownKeys = true } + } } + +internal fun homeScreenLayoutFromStoredValue(storedValue: String?): HomeScreenLayout = + storedValue + ?.let { value -> HomeScreenLayout.entries.firstOrNull { it.name == value } } + ?: HomeScreenLayout.OneCardPerApp diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DownloadFileRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DownloadFileRepository.kt index 8340a14..a241733 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DownloadFileRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DownloadFileRepository.kt @@ -15,5 +15,9 @@ interface DownloadFileRepository { * @param onProgress A callback function to report download progress (bytesDownloaded, totalBytes). * @return A [org.mozilla.tryfox.data.NetworkResult] indicating success with the downloaded [File] or an [org.mozilla.tryfox.data.NetworkResult.Error] on failure. */ - suspend fun downloadFile(downloadUrl: String, outputFile: File, onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit): NetworkResult + suspend fun downloadFile( + downloadUrl: String, + outputFile: File, + onProgress: suspend (bytesDownloaded: Long, totalBytes: Long) -> Unit, + ): NetworkResult } diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/FocusBetaReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/FocusBetaReleaseRepository.kt new file mode 100644 index 0000000..b564d46 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/FocusBetaReleaseRepository.kt @@ -0,0 +1,21 @@ +package org.mozilla.tryfox.data.repositories + +import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.model.MozillaArchiveApk +import org.mozilla.tryfox.util.FOCUS_BETA + +/** Version-aware archive source for Focus Beta. */ +class FocusBetaReleaseRepository( + private val mozillaArchiveRepository: MozillaArchiveRepository, +) : VersionAwareReleaseRepository { + override val appName: String = FOCUS_BETA + + override suspend fun getLatestReleases(): NetworkResult> = + mozillaArchiveRepository.getFocusBetaBuilds() + + override suspend fun getAvailableReleaseVersions(): NetworkResult> = + mozillaArchiveRepository.getFocusBetaVersions() + + override suspend fun getReleasesForVersion(version: String): NetworkResult> = + mozillaArchiveRepository.getFocusBetaBuildsForVersion(version) +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepository.kt new file mode 100644 index 0000000..07e21a6 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepository.kt @@ -0,0 +1,79 @@ +package org.mozilla.tryfox.data.repositories + +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.Serializable +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.io.File + +/** Durable snapshot of successful home-screen release responses. */ +interface HomeDataCacheRepository { + suspend fun read(): HomeDataSnapshot? + suspend fun write(snapshot: HomeDataSnapshot) +} + +object EmptyHomeDataCacheRepository : HomeDataCacheRepository { + override suspend fun read(): HomeDataSnapshot? = null + override suspend fun write(snapshot: HomeDataSnapshot) = Unit +} + +@Serializable +data class HomeDataSnapshot( + val version: Int, + val apps: List, +) { + companion object { + const val CURRENT_VERSION = 1 + } +} + +@Serializable +data class CachedHomeApp( + val appName: String, + val apks: List, + val selectedReleaseVersion: String? = null, + val availableReleaseVersions: List = emptyList(), +) + +@Serializable +data class CachedHomeApk( + val originalString: String, + val rawDateString: String?, + val appName: String, + val version: String, + val abiName: String, + val fullUrl: String, + val fileName: String, +) + +class DefaultHomeDataCacheRepository( + context: Context, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val json: Json = Json { ignoreUnknownKeys = true }, +) : HomeDataCacheRepository { + private val cacheFile = File(context.filesDir, "home-data-cache-v1.json") + + override suspend fun read(): HomeDataSnapshot? = withContext(ioDispatcher) { + runCatching { + if (!cacheFile.exists()) return@runCatching null + json.decodeFromString(cacheFile.readText()) + .takeIf { it.version == HomeDataSnapshot.CURRENT_VERSION } + }.getOrNull() + } + + override suspend fun write(snapshot: HomeDataSnapshot) = withContext(ioDispatcher) { + runCatching { + cacheFile.parentFile?.mkdirs() + val temporaryFile = File(cacheFile.parentFile, "${cacheFile.name}.tmp") + temporaryFile.writeText(json.encodeToString(snapshot)) + if (!temporaryFile.renameTo(cacheFile)) { + temporaryFile.delete() + } + } + Unit + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/InstalledTryBuildRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/InstalledTryBuildRepository.kt new file mode 100644 index 0000000..40abe06 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/InstalledTryBuildRepository.kt @@ -0,0 +1,48 @@ +package org.mozilla.tryfox.data.repositories + +import android.content.Context +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.stringPreferencesKey +import androidx.datastore.preferences.preferencesDataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.serialization.decodeFromString +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.mozilla.tryfox.data.InstalledTryBuild + +interface InstalledTryBuildRepository { + val installedTryBuild: Flow + + suspend fun save(build: InstalledTryBuild) +} + +object EmptyInstalledTryBuildRepository : InstalledTryBuildRepository { + override val installedTryBuild: Flow = flowOf(null) + + override suspend fun save(build: InstalledTryBuild) = Unit +} + +class DefaultInstalledTryBuildRepository(private val appContext: Context) : InstalledTryBuildRepository { + private val Context.dataStore: DataStore by preferencesDataStore(name = "installed_try_build") + + override val installedTryBuild: Flow = appContext.dataStore.data.map { preferences -> + preferences[INSTALLED_TRY_BUILD]?.let { encoded -> + runCatching { json.decodeFromString(encoded) }.getOrNull() + } + } + + override suspend fun save(build: InstalledTryBuild) { + appContext.dataStore.edit { preferences -> + preferences[INSTALLED_TRY_BUILD] = json.encodeToString(build) + } + } + + private companion object { + val INSTALLED_TRY_BUILD = stringPreferencesKey("installed_try_build") + val json = Json { ignoreUnknownKeys = true } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt index 600513a..157e5d4 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt @@ -32,7 +32,13 @@ interface MozillaArchiveRepository { suspend fun getFocusReleaseBuilds(): NetworkResult> + suspend fun getFocusBetaBuilds(): NetworkResult> + suspend fun getFocusReleaseVersions(): NetworkResult> + suspend fun getFocusBetaVersions(): NetworkResult> + suspend fun getFocusReleaseBuildsForVersion(version: String): NetworkResult> + + suspend fun getFocusBetaBuildsForVersion(version: String): NetworkResult> } diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt index a6c647a..87d8eee 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt @@ -7,7 +7,27 @@ import org.mozilla.tryfox.data.TreeherderRevisionResponse interface TreeherderRepository { suspend fun getPushByRevision(project: String, revision: String): NetworkResult + + /** Legacy, project-independent lookup kept for callers which predate project selection. */ suspend fun getPushesByAuthor(author: String): NetworkResult + + /** Looks up an author's pushes in the selected Treeherder project. */ + suspend fun getPushesByAuthor( + project: String, + author: String, + count: Int = 10, + offset: Int = 0, + pushTimestampLte: Long? = null, + ): NetworkResult = + getPushesByAuthor(author) + + /** Gets the most recent pushes for a project, as shown by Treeherder without a query. */ + suspend fun getRecentPushes( + project: String, + count: Int = 10, + offset: Int = 0, + ): NetworkResult = + NetworkResult.Error("Recent-push lookup is not implemented.") suspend fun getJobsForPush(pushId: Int): NetworkResult suspend fun getJobsForPushPage( pushId: Int, diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt index 1a87c79..4ad6628 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt @@ -1,7 +1,9 @@ package org.mozilla.tryfox.data.repositories import kotlinx.coroutines.flow.Flow +import org.mozilla.tryfox.data.SearchHistoryEntry import org.mozilla.tryfox.lan.LanReceiveIdentity +import org.mozilla.tryfox.model.HomeScreenLayout /** * A repository that stores the last searched email. @@ -12,12 +14,16 @@ interface UserDataRepository { * A flow that emits the last searched email. */ val lastSearchedEmailFlow: Flow + val searchHistoryFlow: Flow> val lanReceiveIdentityFlow: Flow + val homeScreenLayoutFlow: Flow /** * Saves the last searched email. * @param email The email to save. */ suspend fun saveLastSearchedEmail(email: String) + suspend fun recordSearch(project: String, query: String, searchedAt: Long = System.currentTimeMillis()) suspend fun saveLanReceiveIdentity(identity: LanReceiveIdentity) + suspend fun saveHomeScreenLayout(layout: HomeScreenLayout) } 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 400fdbb..78bdbb9 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.di +import androidx.work.WorkManager import com.jakewharton.retrofit2.converter.kotlinx.serialization.asConverterFactory import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.Dispatchers @@ -21,6 +22,8 @@ import org.mozilla.tryfox.data.managers.DefaultIntentManager import org.mozilla.tryfox.data.managers.IntentManager import org.mozilla.tryfox.data.repositories.DefaultDownloadFileRepository import org.mozilla.tryfox.data.repositories.DefaultHistoryRepository +import org.mozilla.tryfox.data.repositories.DefaultHomeDataCacheRepository +import org.mozilla.tryfox.data.repositories.DefaultInstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.DefaultMozillaArchiveRepository import org.mozilla.tryfox.data.repositories.DefaultTreeherderRepository import org.mozilla.tryfox.data.repositories.DefaultUserDataRepository @@ -28,15 +31,24 @@ import org.mozilla.tryfox.data.repositories.DownloadFileRepository import org.mozilla.tryfox.data.repositories.FenixBetaReleaseRepository import org.mozilla.tryfox.data.repositories.FenixReleaseReleaseRepository import org.mozilla.tryfox.data.repositories.FenixReleaseRepository +import org.mozilla.tryfox.data.repositories.FocusBetaReleaseRepository import org.mozilla.tryfox.data.repositories.FocusNightlyRepository import org.mozilla.tryfox.data.repositories.FocusReleaseRepository import org.mozilla.tryfox.data.repositories.HistoryRepository +import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository +import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.MozillaArchiveRepository import org.mozilla.tryfox.data.repositories.ReferenceBrowserReleaseRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository import org.mozilla.tryfox.data.repositories.TryFoxReleaseRepository import org.mozilla.tryfox.data.repositories.UserDataRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadStore +import org.mozilla.tryfox.download.DefaultApkDownloadCoordinator +import org.mozilla.tryfox.download.DefaultApkDownloadStore +import org.mozilla.tryfox.download.DownloadNotificationFactory +import org.mozilla.tryfox.install.ApkInstallCoordinator import org.mozilla.tryfox.lan.DefaultLanMessageHistoryRepository import org.mozilla.tryfox.lan.LanMessageHistoryRepository import org.mozilla.tryfox.lan.LanReceiveIdentityManager @@ -47,13 +59,16 @@ import org.mozilla.tryfox.network.MozillaArchivesApiService import org.mozilla.tryfox.network.TreeherderApiService import org.mozilla.tryfox.ui.screens.HistoryViewModel import org.mozilla.tryfox.ui.screens.HomeViewModel -import org.mozilla.tryfox.ui.screens.ProfileViewModel import org.mozilla.tryfox.ui.screens.ReceiveFromDesktopViewModel import org.mozilla.tryfox.ui.screens.ReceiveMessageHistoryViewModel +import org.mozilla.tryfox.ui.screens.SearchHistoryViewModel +import org.mozilla.tryfox.ui.screens.SearchViewModel +import org.mozilla.tryfox.ui.screens.SettingsViewModel import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TRYFOX @@ -146,7 +161,9 @@ val repositoryModule = module { single { DefaultTreeherderRepository(get()) } single { DefaultMozillaArchiveRepository(get()) } single { DefaultUserDataRepository(androidContext()) } + single { DefaultHomeDataCacheRepository(androidContext(), get(named("IODispatcher"))) } single { DefaultHistoryRepository(androidContext(), get(named("IODispatcher"))) } + single { DefaultInstalledTryBuildRepository(androidContext()) } single { DefaultLanMessageHistoryRepository( androidContext(), @@ -164,11 +181,17 @@ val repositoryModule = module { ) } single { DefaultIntentManager(androidContext()) } + single { ApkInstallCoordinator(androidContext(), get()) } + single { DefaultApkDownloadStore(androidContext(), get(named("IODispatcher"))) } + single { DownloadNotificationFactory(androidContext()) } + single { WorkManager.getInstance(androidContext()) } + single { DefaultApkDownloadCoordinator(androidContext(), get(), get()) } single(named(FENIX)) { FenixReleaseRepository(get()) } single(named(FENIX_RELEASE)) { FenixReleaseReleaseRepository(get()) } single(named(FENIX_BETA)) { FenixBetaReleaseRepository(get()) } single(named(FOCUS)) { FocusNightlyRepository(get()) } + single(named(FOCUS_BETA)) { FocusBetaReleaseRepository(get()) } single(named(FOCUS_RELEASE)) { FocusReleaseRepository(get()) } single(named(REFERENCE_BROWSER)) { ReferenceBrowserReleaseRepository() } single(named(TRYFOX)) { TryFoxReleaseRepository(get()) } @@ -189,12 +212,15 @@ val viewModelModule = module { viewModel { HistoryViewModel(get(), get(), get(), get(), get(named("IODispatcher"))) } viewModel { ReceiveFromDesktopViewModel(get()) } viewModel { ReceiveMessageHistoryViewModel(get(), get(named("IODispatcher"))) } + viewModel { SearchHistoryViewModel(get()) } + viewModel { SettingsViewModel(get(), get(), get()) } viewModel { val releaseRepositories = listOf( get(named(FENIX)), get(named(FENIX_RELEASE)), get(named(FENIX_BETA)), get(named(FOCUS)), + get(named(FOCUS_BETA)), get(named(FOCUS_RELEASE)), get(named(REFERENCE_BROWSER)), get(named(TRYFOX)), @@ -205,10 +231,16 @@ val viewModelModule = module { get(), get(), get(), + get(), get(named("IODispatcher")), + get(), + get(), + get(), ) } - viewModel { params -> ProfileViewModel(get(), get(), get(), get(), get(), get(), params.getOrNull()) } + viewModel { params -> + SearchViewModel(get(), get(), get(), get(), get(), get(), params.getOrNull(), project = params.getOrNull() ?: "try") + } } val appModules = listOf(dispatchersModule, networkModule, repositoryModule, viewModelModule) diff --git a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadCoordinator.kt new file mode 100644 index 0000000..92c2b39 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadCoordinator.kt @@ -0,0 +1,14 @@ +package org.mozilla.tryfox.download + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.mozilla.tryfox.download.model.PersistedDownloadState + +interface ApkDownloadCoordinator { + val downloads: StateFlow> + + fun enqueue(request: ApkDownloadRequest): String + fun retry(request: ApkDownloadRequest): String + fun cancel(uniqueKey: String) + fun observe(uniqueKey: String): Flow +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt new file mode 100644 index 0000000..1cc3290 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadRequest.kt @@ -0,0 +1,14 @@ +package org.mozilla.tryfox.download + +import java.io.File + +data class ApkDownloadRequest( + val uniqueKey: String, + val downloadUrl: String, + val outputFile: File, + val appName: String, + val fileName: String, + val cacheRelativePath: String? = null, +) { + val outputPath: String = outputFile.absolutePath +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt new file mode 100644 index 0000000..0de0a0f --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt @@ -0,0 +1,100 @@ +package org.mozilla.tryfox.download + +import android.content.Context +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.serialization.SerializationException +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.mozilla.tryfox.download.model.PersistedDownloadState +import java.io.File + +interface ApkDownloadStore { + val downloads: StateFlow> + + fun observe(uniqueKey: String): Flow + fun get(uniqueKey: String): PersistedDownloadState? + fun upsert(state: PersistedDownloadState) + fun remove(uniqueKey: String) + fun clear() +} + +class DefaultApkDownloadStore( + context: Context, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val json: Json = Json { + ignoreUnknownKeys = true + encodeDefaults = true + prettyPrint = true + }, +) : ApkDownloadStore { + private val scope = kotlinx.coroutines.CoroutineScope(SupervisorJob() + ioDispatcher) + private val lock = Mutex() + private val stateFile = File(context.filesDir, "apk-download-state.json") + private val _downloads = MutableStateFlow(loadInitialState()) + override val downloads: StateFlow> = _downloads.asStateFlow() + + override fun observe(uniqueKey: String): Flow = downloads.map { it[uniqueKey] } + + override fun get(uniqueKey: String): PersistedDownloadState? = downloads.value[uniqueKey] + + override fun upsert(state: PersistedDownloadState) { + _downloads.value = _downloads.value + (state.uniqueKey to state) + schedulePersist() + } + + override fun remove(uniqueKey: String) { + _downloads.value = _downloads.value - uniqueKey + schedulePersist() + } + + override fun clear() { + _downloads.value = emptyMap() + schedulePersist() + } + + private fun schedulePersist() { + scope.launch { + lock.withLock { + persistLocked(_downloads.value) + } + } + } + + private fun loadInitialState(): Map = + try { + if (!stateFile.exists()) { + emptyMap() + } else { + runBlocking(ioDispatcher) { + lock.withLock { + val raw = stateFile.readText() + json.decodeFromString>(raw) + } + } + } + } catch (_: SerializationException) { + emptyMap() + } catch (_: Exception) { + emptyMap() + } + + private fun persistLocked(downloads: Map) { + try { + stateFile.parentFile?.mkdirs() + stateFile.writeText(json.encodeToString(downloads)) + } catch (_: Exception) { + // Best effort persistence. The in-memory state remains authoritative until the next write. + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt new file mode 100644 index 0000000..4ebe117 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt @@ -0,0 +1,87 @@ +package org.mozilla.tryfox.download + +import android.content.Context +import android.util.Log +import androidx.work.ExistingWorkPolicy +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.OutOfQuotaPolicy +import androidx.work.WorkManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.StateFlow +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.download.worker.ApkDownloadWorker + +class DefaultApkDownloadCoordinator( + context: Context, + private val store: ApkDownloadStore = DefaultApkDownloadStore(context.applicationContext), + private val workManager: WorkManager = WorkManager.getInstance(context.applicationContext), +) : ApkDownloadCoordinator { + private companion object { + const val TAG = "ApkDownloadCoordinator" + } + + override val downloads: StateFlow> = store.downloads + + override fun enqueue(request: ApkDownloadRequest): String { + val workRequest = + OneTimeWorkRequestBuilder() + .setInputData(ApkDownloadWorker.createInputData(request)) + .addTag(request.uniqueKey) + .setExpedited(OutOfQuotaPolicy.RUN_AS_NON_EXPEDITED_WORK_REQUEST) + .build() + + store.upsert( + request.toPersistedState( + status = DownloadStatus.QUEUED, + workId = workRequest.id.toString(), + ), + ) + workManager.enqueueUniqueWork(request.uniqueKey, ExistingWorkPolicy.REPLACE, workRequest) + Log.d( + TAG, + "enqueued uniqueKey=${request.uniqueKey} workId=${workRequest.id} " + + "outputPath=${request.outputPath}", + ) + return workRequest.id.toString() + } + + override fun retry(request: ApkDownloadRequest): String { + return enqueue(request) + } + + override fun cancel(uniqueKey: String) { + workManager.cancelUniqueWork(uniqueKey) + store.get(uniqueKey)?.let { current -> + store.upsert( + current.copy( + status = DownloadStatus.CANCELED, + updatedAt = System.currentTimeMillis(), + ), + ) + } + } + + override fun observe(uniqueKey: String): Flow = store.observe(uniqueKey) + + private fun ApkDownloadRequest.toPersistedState( + status: DownloadStatus, + workId: String? = null, + bytesDownloaded: Long = 0L, + totalBytes: Long = -1L, + errorMessage: String? = null, + ): PersistedDownloadState = + PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + errorMessage = errorMessage, + workId = workId, + ) +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt new file mode 100644 index 0000000..916daa1 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt @@ -0,0 +1,65 @@ +package org.mozilla.tryfox.download + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Context.NOTIFICATION_SERVICE +import android.content.pm.ServiceInfo +import android.os.Build +import androidx.core.app.NotificationCompat +import androidx.work.ForegroundInfo +import org.mozilla.tryfox.R + +class DownloadNotificationFactory( + private val context: Context, +) { + fun createForegroundInfo( + appName: String, + progress: Int? = null, + isIndeterminate: Boolean = true, + ): ForegroundInfo { + ensureChannel() + val notification = NotificationCompat.Builder(context, CHANNEL_ID) + .setSmallIcon(R.mipmap.ic_launcher) + .setContentTitle(context.getString(R.string.download_notification_title, appName)) + .setContentText( + if (progress == null) { + context.getString(R.string.download_notification_in_progress) + } else { + context.getString(R.string.download_notification_progress, progress.coerceIn(0, 100)) + }, + ) + .setOngoing(true) + .setOnlyAlertOnce(true) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setProgress(100, progress ?: 0, isIndeterminate) + .build() + + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ForegroundInfo(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC) + } else { + ForegroundInfo(NOTIFICATION_ID, notification) + } + } + + private fun ensureChannel() { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return + val notificationManager = context.getSystemService(NOTIFICATION_SERVICE) as NotificationManager + val existingChannel = notificationManager.getNotificationChannel(CHANNEL_ID) + if (existingChannel != null) return + + val channel = NotificationChannel( + CHANNEL_ID, + context.getString(R.string.download_notification_channel_name), + NotificationManager.IMPORTANCE_LOW, + ).apply { + description = context.getString(R.string.download_notification_channel_description) + } + notificationManager.createNotificationChannel(channel) + } + + private companion object { + const val CHANNEL_ID = "tryfox_downloads" + const val NOTIFICATION_ID = 0x7478 + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/model/PersistedDownloadState.kt b/app/src/main/java/org/mozilla/tryfox/download/model/PersistedDownloadState.kt new file mode 100644 index 0000000..8487041 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/model/PersistedDownloadState.kt @@ -0,0 +1,35 @@ +package org.mozilla.tryfox.download.model + +import kotlinx.serialization.Serializable + +@Serializable +enum class DownloadStatus { + QUEUED, + RUNNING, + SUCCEEDED, + FAILED, + CANCELED, +} + +@Serializable +data class PersistedDownloadState( + val uniqueKey: String, + val downloadUrl: String, + val outputPath: String, + val appName: String, + val fileName: String, + val cacheRelativePath: String? = null, + val status: DownloadStatus = DownloadStatus.QUEUED, + val bytesDownloaded: Long = 0L, + val totalBytes: Long = -1L, + val errorMessage: String? = null, + val workId: String? = null, + val createdAt: Long = System.currentTimeMillis(), + val updatedAt: Long = createdAt, +) { + val progress: Float? + get() = if (totalBytes > 0L) bytesDownloaded.toFloat() / totalBytes.toFloat() else null + + val isTerminal: Boolean + get() = status == DownloadStatus.SUCCEEDED || status == DownloadStatus.FAILED || status == DownloadStatus.CANCELED +} diff --git a/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt b/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt new file mode 100644 index 0000000..98a43ba --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt @@ -0,0 +1,333 @@ +package org.mozilla.tryfox.download.worker + +import android.content.Context +import android.util.Log +import androidx.work.CoroutineWorker +import androidx.work.Data +import androidx.work.ForegroundInfo +import androidx.work.WorkerParameters +import androidx.work.workDataOf +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.koin.core.component.KoinComponent +import org.koin.core.component.inject +import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.managers.CacheManager +import org.mozilla.tryfox.data.repositories.DownloadFileRepository +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.ApkDownloadStore +import org.mozilla.tryfox.download.DownloadNotificationFactory +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import java.io.File + +class ApkDownloadWorker( + appContext: Context, + params: WorkerParameters, +) : CoroutineWorker(appContext, params), KoinComponent { + private val downloadFileRepository: DownloadFileRepository by inject() + private val cacheManager: CacheManager by inject() + private val downloadStore: ApkDownloadStore by inject() + private val notificationFactory: DownloadNotificationFactory by inject() + + /** Required before [doWork] when the request is scheduled as expedited work. */ + override suspend fun getForegroundInfo(): ForegroundInfo { + val request = inputData.toRequest() + return notificationFactory.createForegroundInfo(request?.appName.orEmpty()) + } + + override suspend fun doWork(): Result { + val request = inputData.toRequest() ?: return Result.failure() + Log.d(TAG, "started uniqueKey=${request.uniqueKey} workerId=$id outputPath=${request.outputPath}") + val startedAt = System.currentTimeMillis() + var lastBytesDownloaded = 0L + var lastTotalBytes = -1L + var lastProgressUpdateAt = 0L + var lastProgressPercent = -1 + setForeground(notificationFactory.createForegroundInfo(request.appName)) + + updateState( + request = request, + status = DownloadStatus.RUNNING, + startedAt = startedAt, + bytesDownloaded = 0L, + totalBytes = -1L, + ) + + val outputFile = File(request.outputPath) + outputFile.parentFile?.mkdirs() + + return try { + when ( + val result = withContext(Dispatchers.IO) { + downloadFileRepository.downloadFile( + downloadUrl = request.downloadUrl, + outputFile = outputFile, + ) { bytesDownloaded, totalBytes -> + lastBytesDownloaded = bytesDownloaded + lastTotalBytes = totalBytes + val progressPercent = + if (totalBytes > 0) ((bytesDownloaded * 100) / totalBytes).toInt() else -1 + val now = System.currentTimeMillis() + val elapsedSinceLastUpdate = now - lastProgressUpdateAt + val shouldPublish = + if (totalBytes <= 0) { + // Some Taskcluster artifact responses omit Content-Length. The + // progress UI is necessarily indeterminate, but publishing on + // every 4 KiB read overwhelms the state store and logcat. + lastProgressUpdateAt == 0L || elapsedSinceLastUpdate >= PROGRESS_UPDATE_INTERVAL_MS + } else { + lastProgressPercent < 0 || + bytesDownloaded == totalBytes || + progressPercent >= lastProgressPercent + MIN_PROGRESS_PERCENT_STEP || + elapsedSinceLastUpdate >= PROGRESS_UPDATE_INTERVAL_MS + } + if (shouldPublish) { + lastProgressUpdateAt = now + lastProgressPercent = progressPercent + updateState( + request = request, + status = DownloadStatus.RUNNING, + startedAt = startedAt, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + ) + setProgress( + workDataOf( + KEY_UNIQUE_KEY to request.uniqueKey, + KEY_BYTES_DOWNLOADED to bytesDownloaded, + KEY_TOTAL_BYTES to totalBytes, + ), + ) + setForeground( + notificationFactory.createForegroundInfo( + appName = request.appName, + progress = if (totalBytes > 0) progressPercent else null, + isIndeterminate = totalBytes <= 0, + ), + ) + } + } + } + ) { + is NetworkResult.Success -> { + val downloadedFile = result.data.takeIf { it.exists() } ?: outputFile.takeIf { it.exists() } + if (downloadedFile == null) { + updateFailure( + request = request, + message = "Downloaded file is missing", + startedAt = startedAt, + ) + Result.failure( + workDataOf( + KEY_UNIQUE_KEY to request.uniqueKey, + KEY_ERROR_MESSAGE to "Downloaded file is missing", + ), + ) + } else { + Log.d( + TAG, + "download completed uniqueKey=${request.uniqueKey} file=${downloadedFile.absolutePath} " + + "bytes=$lastBytesDownloaded total=$lastTotalBytes", + ) + updateSuccess( + request = request, + startedAt = startedAt, + bytesDownloaded = lastBytesDownloaded, + totalBytes = lastTotalBytes, + ) + cacheManager.checkCacheStatus() + Result.success( + workDataOf( + KEY_UNIQUE_KEY to request.uniqueKey, + KEY_OUTPUT_PATH to downloadedFile.absolutePath, + ), + ) + } + } + + is NetworkResult.Error -> { + Log.e(TAG, "download failed uniqueKey=${request.uniqueKey}: ${result.message}") + updateFailure( + request = request, + message = result.message, + startedAt = startedAt, + ) + cacheManager.checkCacheStatus() + Result.failure( + workDataOf( + KEY_UNIQUE_KEY to request.uniqueKey, + KEY_ERROR_MESSAGE to result.message, + ), + ) + } + } + } catch (e: CancellationException) { + Log.w(TAG, "download cancelled uniqueKey=${request.uniqueKey}") + updateCanceled(request = request, startedAt = startedAt) + cacheManager.checkCacheStatus() + throw e + } + } + + private fun updateSuccess( + request: ApkDownloadRequest, + startedAt: Long, + bytesDownloaded: Long, + totalBytes: Long, + ) { + if (!isCurrentRequest(request)) return + downloadStore.upsert( + request.toPersistedState( + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + updatedAt = System.currentTimeMillis(), + createdAt = startedAt, + ), + ) + } + + private fun updateFailure(request: ApkDownloadRequest, message: String?, startedAt: Long) { + if (!isCurrentRequest(request)) return + Log.e(TAG, "recording failure uniqueKey=${request.uniqueKey}: $message") + downloadStore.upsert( + request.toPersistedState( + status = DownloadStatus.FAILED, + errorMessage = message, + updatedAt = System.currentTimeMillis(), + createdAt = startedAt, + ), + ) + } + + private fun updateCanceled(request: ApkDownloadRequest, startedAt: Long) { + if (!isCurrentRequest(request)) return + downloadStore.upsert( + request.toPersistedState( + status = DownloadStatus.CANCELED, + updatedAt = System.currentTimeMillis(), + createdAt = startedAt, + ), + ) + } + + private fun updateState( + request: ApkDownloadRequest, + status: DownloadStatus, + startedAt: Long, + bytesDownloaded: Long, + totalBytes: Long, + ) { + if (!isCurrentRequest(request)) return + downloadStore.upsert( + request.toPersistedState( + status = status, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + updatedAt = System.currentTimeMillis(), + createdAt = startedAt, + ), + ) + } + + private fun ApkDownloadRequest.toPersistedState( + status: DownloadStatus, + bytesDownloaded: Long = 0L, + totalBytes: Long = -1L, + errorMessage: String? = null, + workId: String? = null, + createdAt: Long = System.currentTimeMillis(), + updatedAt: Long = createdAt, + ): PersistedDownloadState = + downloadStore.get(uniqueKey)?.let { existing -> + PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + errorMessage = errorMessage, + workId = workId ?: existing.workId, + createdAt = existing.createdAt, + updatedAt = updatedAt, + ) + } ?: PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + bytesDownloaded = bytesDownloaded, + totalBytes = totalBytes, + errorMessage = errorMessage, + workId = workId, + createdAt = createdAt, + updatedAt = updatedAt, + ) + + private fun isCurrentRequest(request: ApkDownloadRequest): Boolean { + val persistedState = downloadStore.get(request.uniqueKey) + val isCurrent = persistedState?.workId == id.toString() && persistedState.status != DownloadStatus.CANCELED + if (!isCurrent) { + Log.w( + TAG, + "ignoring stale worker uniqueKey=${request.uniqueKey} workerId=$id " + + "storedWorkId=${persistedState?.workId} storedStatus=${persistedState?.status}", + ) + } + return isCurrent + } + + private fun Data.toRequest(): ApkDownloadRequest? { + val uniqueKey = getString(KEY_UNIQUE_KEY) ?: return null + val downloadUrl = getString(KEY_DOWNLOAD_URL) ?: return null + val outputPath = getString(KEY_OUTPUT_PATH) ?: return null + val appName = getString(KEY_APP_NAME) ?: return null + val fileName = getString(KEY_FILE_NAME) ?: return null + val cacheRelativePath = getString(KEY_CACHE_RELATIVE_PATH) + + return ApkDownloadRequest( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputFile = File(outputPath), + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + ) + } + + companion object { + private const val TAG = "ApkDownloadWorker" + private const val PROGRESS_UPDATE_INTERVAL_MS = 500L + private const val MIN_PROGRESS_PERCENT_STEP = 5 + const val KEY_UNIQUE_KEY = "download_unique_key" + const val KEY_DOWNLOAD_URL = "download_url" + const val KEY_OUTPUT_PATH = "download_output_path" + const val KEY_APP_NAME = "download_app_name" + const val KEY_FILE_NAME = "download_file_name" + const val KEY_CACHE_RELATIVE_PATH = "download_cache_relative_path" + const val KEY_BYTES_DOWNLOADED = "download_bytes_downloaded" + const val KEY_TOTAL_BYTES = "download_total_bytes" + const val KEY_ERROR_MESSAGE = "download_error_message" + + fun createInputData(request: ApkDownloadRequest): Data = + Data.Builder() + .putString(KEY_UNIQUE_KEY, request.uniqueKey) + .putString(KEY_DOWNLOAD_URL, request.downloadUrl) + .putString(KEY_OUTPUT_PATH, request.outputPath) + .putString(KEY_APP_NAME, request.appName) + .putString(KEY_FILE_NAME, request.fileName) + .apply { + request.cacheRelativePath?.let { putString(KEY_CACHE_RELATIVE_PATH, it) } + } + .build() + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt new file mode 100644 index 0000000..1db60b3 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt @@ -0,0 +1,333 @@ +package org.mozilla.tryfox.install + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.net.Uri +import android.os.Build +import androidx.core.content.pm.PackageInfoCompat +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharedFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import logcat.LogPriority +import logcat.logcat +import org.mozilla.tryfox.data.InstalledTryBuild +import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE +import java.io.File +import java.util.concurrent.atomic.AtomicInteger + +/** Owns every APK installation session started by TryFox. */ +@Suppress("NestedBlockDepth", "TooManyFunctions") +class ApkInstallCoordinator( + private val context: Context, + private val installedTryBuildRepository: InstalledTryBuildRepository, +) { + private data class Operation( + val artifactKey: String, + val file: File, + val packageName: String, + val versionName: String?, + val versionCode: Long, + val provenance: TryBuildProvenance?, + ) + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val sessionFactory = PackageInstallerSessionFactory(context.packageManager.packageInstaller) + private val requestCodes = AtomicInteger(10_000) + private val operations = mutableMapOf() + private var activeOperationId: String? = null + + private val _states = MutableStateFlow>(emptyMap()) + val states: StateFlow> = _states.asStateFlow() + private val _uninstallRequests = MutableSharedFlow(extraBufferCapacity = 1) + val uninstallRequests: SharedFlow = _uninstallRequests.asSharedFlow() + private val _successfulInstalls = MutableSharedFlow(extraBufferCapacity = 1) + val successfulInstalls: SharedFlow = _successfulInstalls.asSharedFlow() + + fun install(artifactKey: String, file: File, provenance: TryBuildProvenance? = null) { + if (activeOperationId != null) return + activeOperationId = artifactKey + _states.value = _states.value + (artifactKey to InstallState.Installing) + scope.launch { prepareAndInstall(artifactKey, file, provenance) } + } + + fun cancelConflict(artifactKey: String) { + if (activeOperationId == artifactKey) activeOperationId = null + operations.remove(artifactKey) + _states.value = _states.value + (artifactKey to InstallState.Idle) + } + + fun confirmUninstallAndRetry(artifactKey: String) { + val operation = operations[artifactKey] ?: return + _states.value = _states.value + (artifactKey to InstallState.Uninstalling) + _uninstallRequests.tryEmit(UninstallRequest(artifactKey, operation.packageName)) + } + + fun onUninstallResult(artifactKey: String, succeeded: Boolean) { + val operation = operations[artifactKey] ?: return + if (!succeeded || isInstalled(operation.packageName)) { + logcat(LogPriority.WARN, TAG) { + "Uninstall failed artifactKey=$artifactKey package=${operation.packageName} " + + "activitySucceeded=$succeeded packageStillInstalled=${isInstalled(operation.packageName)}" + } + fail(artifactKey, "Uninstall was canceled or did not complete.") + return + } + _states.value = _states.value + (artifactKey to InstallState.Installing) + scope.launch { commit(operation) } + } + + fun openInstalledApp(packageName: String) { + val launchIntent = context.packageManager.getLaunchIntentForPackage(packageName) + if (launchIntent == null) { + logcat(LogPriority.WARN, TAG) { "No launch activity for $packageName" } + return + } + launchIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(launchIntent) + } + + suspend fun onInstallResult(intent: Intent) { + val artifactKey = intent.getStringExtra(EXTRA_ARTIFACT_KEY) ?: return + val inMemoryOperation = operations[artifactKey] + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val statusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE).orEmpty() + if (status == PackageInstaller.STATUS_SUCCESS) { + val operation = inMemoryOperation ?: intent.toResultOperation(artifactKey) ?: return + succeed(artifactKey, operation) + return + } + val operation = inMemoryOperation ?: return + when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + @Suppress("DEPRECATION") + val confirmationIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT) + if (confirmationIntent == null) { + fail(artifactKey, "Android did not provide an installation confirmation screen.") + } else { + confirmationIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(confirmationIntent) + } + } + + PackageInstaller.STATUS_FAILURE_CONFLICT -> { + if (statusMessage.contains(SHARED_USER_SIGNATURE_FAILURE)) { + logcat(LogPriority.WARN, TAG) { + "Shared-user signature conflict artifactKey=$artifactKey package=${operation.packageName} " + + "message=$statusMessage" + } + fail(artifactKey, SHARED_USER_SIGNATURE_USER_MESSAGE) + return + } + val conflictingPackage = intent.getStringExtra(PackageInstaller.EXTRA_OTHER_PACKAGE_NAME) ?: operation.packageName + logcat(LogPriority.WARN, TAG) { + "Install conflict artifactKey=$artifactKey package=${operation.packageName} " + + "conflictingPackage=$conflictingPackage message=$statusMessage" + } + conflict(artifactKey, conflictingPackage) + } + else -> { + if (statusMessage.contains("VERSION_DOWNGRADE", ignoreCase = true) && isInstalled(operation.packageName)) { + conflict(artifactKey, operation.packageName) + } else { + logcat(LogPriority.WARN, TAG) { "Install failed status=$status message=$statusMessage" } + fail(artifactKey, userMessage(status)) + } + } + } + } + + private fun prepareAndInstall(artifactKey: String, file: File, provenance: TryBuildProvenance?) { + if (!file.isFile) { + fail(artifactKey, "The downloaded APK is no longer available.") + return + } + val archive = context.packageManager.getPackageArchiveInfo(file.absolutePath, 0) + val packageName = archive?.packageName + if (packageName == null) { + fail(artifactKey, "The downloaded file is not a valid APK.") + return + } + val incomingVersion = archive.let(PackageInfoCompat::getLongVersionCode) + val operation = Operation(artifactKey, file, packageName, archive.versionName, incomingVersion, provenance) + operations[artifactKey] = operation + val installedVersion = installedVersion(packageName) + if (installedVersion != null && installedVersion > incomingVersion) { + conflict(artifactKey, packageName) + return + } + commit(operation) + } + + private fun commit(operation: Operation) { + try { + sessionFactory.commit(operation.file, operation.packageName, statusReceiver(operation)) + } catch (e: Exception) { + logcat(LogPriority.ERROR, TAG) { "Could not create install session: ${e.message}" } + fail(operation.artifactKey, "Could not start installation.") + } + } + + private fun statusReceiver(operation: Operation) = PendingIntent.getBroadcast( + context, + requestCodes.incrementAndGet(), + Intent(context, InstallResultReceiver::class.java) + .setData( + Uri.Builder() + .scheme("tryfox") + .authority("install-result") + .appendPath(operation.artifactKey) + .build(), + ) + .putExtra(EXTRA_ARTIFACT_KEY, operation.artifactKey) + .putExtra(EXTRA_PACKAGE_NAME, operation.packageName) + .putExtra(EXTRA_VERSION_NAME, operation.versionName) + .putExtra(EXTRA_VERSION_CODE, operation.versionCode) + .putExtra(EXTRA_PROJECT, operation.provenance?.project) + .putExtra(EXTRA_REVISION, operation.provenance?.revision) + .putExtra(EXTRA_COMMIT_MESSAGE, operation.provenance?.commitMessage), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE, + ).intentSender + + private fun conflict(artifactKey: String, packageName: String) { + _states.value = _states.value + (artifactKey to InstallState.Conflict(packageName)) + } + + private suspend fun succeed(artifactKey: String, operation: Operation) { + val packageName = operation.packageName + operation.provenance + ?.takeIf { packageName == FENIX_DEBUG_PACKAGE } + ?.let { provenance -> + try { + installedTryBuildRepository.save( + InstalledTryBuild( + packageName = packageName, + project = provenance.project, + revision = provenance.revision, + commitMessage = provenance.commitMessage, + versionName = operation.versionName, + versionCode = operation.versionCode, + ), + ) + } catch (exception: Exception) { + logcat(LogPriority.ERROR, TAG) { + "Could not persist Try build provenance for package=$packageName: ${exception.message}" + } + } + } + activeOperationId = null + operations.remove(artifactKey) + _states.value = _states.value + (artifactKey to InstallState.Installed(packageName)) + _successfulInstalls.tryEmit(artifactKey) + } + + private fun Intent.toResultOperation(artifactKey: String): Operation? { + val packageName = getStringExtra(EXTRA_PACKAGE_NAME)?.takeIf(String::isNotBlank) ?: return null + val versionCode = getLongExtra(EXTRA_VERSION_CODE, UNKNOWN_VERSION_CODE) + if (versionCode == UNKNOWN_VERSION_CODE) return null + val project = getStringExtra(EXTRA_PROJECT) + val revision = getStringExtra(EXTRA_REVISION) + val commitMessage = getStringExtra(EXTRA_COMMIT_MESSAGE) + val provenance = if (project != null && revision != null && commitMessage != null) { + TryBuildProvenance(project, revision, commitMessage) + } else { + null + } + return Operation( + artifactKey = artifactKey, + file = File(""), + packageName = packageName, + versionName = getStringExtra(EXTRA_VERSION_NAME), + versionCode = versionCode, + provenance = provenance, + ) + } + + private fun fail(artifactKey: String, message: String) { + val packageName = operations[artifactKey]?.packageName + logcat(LogPriority.ERROR, TAG) { + "Install failed artifactKey=$artifactKey package=$packageName message=$message" + } + activeOperationId = null + operations.remove(artifactKey) + _states.value = _states.value + (artifactKey to InstallState.Failed(message)) + } + + private fun installedVersion(packageName: String): Long? = try { + PackageInfoCompat.getLongVersionCode(context.packageManager.getPackageInfo(packageName, 0)) + } catch (_: PackageManager.NameNotFoundException) { + null + } + + private fun isInstalled(packageName: String) = installedVersion(packageName) != null + + private fun userMessage(status: Int) = when (status) { + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE -> "This APK is not compatible with this device." + PackageInstaller.STATUS_FAILURE_INVALID -> "Android rejected this APK as invalid." + PackageInstaller.STATUS_FAILURE_STORAGE -> "There is not enough storage to install this APK." + PackageInstaller.STATUS_FAILURE_BLOCKED -> "Android blocked this installation." + PackageInstaller.STATUS_FAILURE_ABORTED -> "Installation was canceled." + PackageInstaller.STATUS_FAILURE_TIMEOUT -> "Installation timed out." + else -> "Android could not install this APK." + } + + private companion object { + const val TAG = "ApkInstallCoordinator" + const val EXTRA_ARTIFACT_KEY = "org.mozilla.tryfox.install.ARTIFACT_KEY" + const val EXTRA_PACKAGE_NAME = "org.mozilla.tryfox.install.PACKAGE_NAME" + const val EXTRA_VERSION_NAME = "org.mozilla.tryfox.install.VERSION_NAME" + const val EXTRA_VERSION_CODE = "org.mozilla.tryfox.install.VERSION_CODE" + const val EXTRA_PROJECT = "org.mozilla.tryfox.install.PROJECT" + const val EXTRA_REVISION = "org.mozilla.tryfox.install.REVISION" + const val EXTRA_COMMIT_MESSAGE = "org.mozilla.tryfox.install.COMMIT_MESSAGE" + const val UNKNOWN_VERSION_CODE = Long.MIN_VALUE + const val SHARED_USER_SIGNATURE_FAILURE = "INSTALL_FAILED_SHARED_USER_INCOMPATIBLE" + const val SHARED_USER_SIGNATURE_USER_MESSAGE = + "This build is signed differently from an installed Firefox app. Android cannot install them together. " + + "Uninstall the conflicting Firefox app and its local data, then try again." + } +} + +internal class PackageInstallerSessionFactory( + private val packageInstaller: PackageInstaller, +) { + fun commit(file: File, packageName: String, statusReceiver: IntentSender) { + var sessionId: Int? = null + try { + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + setAppPackageName(packageName) + setSize(file.length()) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + setPackageSource(PackageInstaller.PACKAGE_SOURCE_DOWNLOADED_FILE) + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_REQUIRED) + } + } + sessionId = packageInstaller.createSession(params) + packageInstaller.openSession(sessionId).use { session -> + file.inputStream().use { input -> + session.openWrite("base.apk", 0, file.length()).use { output -> + input.copyTo(output) + session.fsync(output) + } + } + session.commit(statusReceiver) + } + } catch (exception: Exception) { + sessionId?.let(packageInstaller::abandonSession) + throw exception + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt b/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt new file mode 100644 index 0000000..b3ad853 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt @@ -0,0 +1,27 @@ +package org.mozilla.tryfox.install + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.koin.core.context.GlobalContext + +class InstallResultReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + val pendingResult = goAsync() + receiverScope.launch { + try { + GlobalContext.get().get().onInstallResult(intent) + } finally { + pendingResult.finish() + } + } + } + + private companion object { + val receiverScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt b/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt new file mode 100644 index 0000000..4196ee4 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/InstallState.kt @@ -0,0 +1,15 @@ +package org.mozilla.tryfox.install + +sealed interface InstallState { + data object Idle : InstallState + data object Installing : InstallState + data class Conflict(val packageName: String) : InstallState + data object Uninstalling : InstallState + data class Installed(val packageName: String) : InstallState + data class Failed(val message: String) : InstallState +} + +data class UninstallRequest( + val operationId: String, + val packageName: String, +) diff --git a/app/src/main/java/org/mozilla/tryfox/install/TryBuildProvenance.kt b/app/src/main/java/org/mozilla/tryfox/install/TryBuildProvenance.kt new file mode 100644 index 0000000..f0c9a31 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/TryBuildProvenance.kt @@ -0,0 +1,7 @@ +package org.mozilla.tryfox.install + +data class TryBuildProvenance( + val project: String, + val revision: String, + val commitMessage: String, +) diff --git a/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt b/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt index a16957d..dfadcf7 100644 --- a/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt +++ b/app/src/main/java/org/mozilla/tryfox/lan/TryFoxLanReceiveService.kt @@ -35,6 +35,7 @@ import org.mozilla.tryfox.EXTRA_RECEIVE_FROM_DESKTOP_START_REQUESTED import org.mozilla.tryfox.MainActivity import org.mozilla.tryfox.R import org.mozilla.tryfox.data.repositories.TreeherderRepository +import org.mozilla.tryfox.util.withoutTrailingReviewerDirective import java.io.IOException class TryFoxLanReceiveService : Service(), KoinComponent { @@ -462,7 +463,10 @@ class TryFoxLanReceiveService : Service(), KoinComponent { when { !message.title.isNullOrBlank() -> message.title !message.pushComment.isNullOrBlank() -> - getString(R.string.lan_receive_message_notification_title_comment, message.pushComment) + getString( + R.string.lan_receive_message_notification_title_comment, + message.pushComment.withoutTrailingReviewerDirective(), + ) !message.revision.isNullOrBlank() -> getString(R.string.lan_receive_message_notification_title_revision, message.revision) !message.author.isNullOrBlank() -> diff --git a/app/src/main/java/org/mozilla/tryfox/model/AppState.kt b/app/src/main/java/org/mozilla/tryfox/model/AppState.kt index b9c4684..517aa11 100644 --- a/app/src/main/java/org/mozilla/tryfox/model/AppState.kt +++ b/app/src/main/java/org/mozilla/tryfox/model/AppState.kt @@ -20,6 +20,14 @@ data class AppState( val isFromPlayStore: Boolean get() = installingPackageName == PLAY_STORE_PACKAGE + /** Whether the app was installed by TryFox. */ + val isFromTryFox: Boolean + get() = installingPackageName == TRYFOX_PACKAGE + + /** Whether the app was installed from a source other than Play Store or TryFox. */ + val isSideloaded: Boolean + get() = isInstalled && !isFromPlayStore && !isFromTryFox + val formattedInstallDate: String? get() = installDateMillis?.let { val sdf = SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()) @@ -28,5 +36,6 @@ data class AppState( companion object { const val PLAY_STORE_PACKAGE = "com.android.vending" + const val TRYFOX_PACKAGE = "org.mozilla.tryfox" } } diff --git a/app/src/main/java/org/mozilla/tryfox/model/HomeScreenLayout.kt b/app/src/main/java/org/mozilla/tryfox/model/HomeScreenLayout.kt new file mode 100644 index 0000000..396ee89 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/model/HomeScreenLayout.kt @@ -0,0 +1,7 @@ +package org.mozilla.tryfox.model + +/** The persisted arrangement preference for Home app cards. */ +enum class HomeScreenLayout { + OneCardPerApp, + OneCardPerFlavor, +} diff --git a/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt index d4b79ce..4ed723b 100644 --- a/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt +++ b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt @@ -16,11 +16,22 @@ interface TreeherderApiService { @Query("revision") revision: String, ): TreeherderRevisionResponse - @GET("project/try/push/") + @GET("project/{project}/push/") suspend fun getPushByAuthor( + @Path("project") project: String, @Query("full") full: Boolean = true, @Query("count") count: Int = 10, + @Query("offset") offset: Int = 0, @Query("author") author: String, + @Query("push_timestamp__lte") pushTimestampLte: Long? = null, + ): TreeherderRevisionResponse + + @GET("project/{project}/push/") + suspend fun getRecentPushes( + @Path("project") project: String, + @Query("full") full: Boolean = true, + @Query("count") count: Int = 10, + @Query("offset") offset: Int = 0, ): TreeherderRevisionResponse @GET("jobs/") diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppCard.kt deleted file mode 100644 index a86d796..0000000 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppCard.kt +++ /dev/null @@ -1,178 +0,0 @@ -package org.mozilla.tryfox.ui.composables - -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.ArrowDropUp -import androidx.compose.material3.AssistChip -import androidx.compose.material3.AssistChipDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import org.mozilla.tryfox.R -import org.mozilla.tryfox.TryFoxViewModel -import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.ui.models.ArtifactUiModel -import org.mozilla.tryfox.ui.models.JobDetailsUiModel - -@Composable -fun AppCard( - job: JobDetailsUiModel, - viewModel: TryFoxViewModel, -) { - val jobArtifacts = job.artifacts - val (supportedArtifacts, unsupportedArtifacts) = remember(jobArtifacts) { - jobArtifacts.partition { it.abi.isSupported } - } - - ElevatedCard( - modifier = Modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 6.dp), - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 4.dp), - ) { - AppIcon(appName = job.appName, modifier = Modifier.size(24.dp)) - Text( - text = job.jobName, - style = MaterialTheme.typography.titleLarge, - fontWeight = FontWeight.SemiBold, - ) - } - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(bottom = 12.dp), - ) { - AssistChip( - onClick = { /* No action needed */ }, - label = { Text(job.jobSymbol, style = MaterialTheme.typography.labelSmall) }, - colors = AssistChipDefaults.assistChipColors( - containerColor = MaterialTheme.colorScheme.tertiaryContainer, - labelColor = MaterialTheme.colorScheme.onTertiaryContainer, - ), - ) - Spacer(modifier = Modifier.width(8.dp)) - Text( - text = stringResource(id = R.string.app_card_task_id, job.taskId), - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - - if (viewModel.isLoadingJobArtifacts[job.taskId] == true && job.artifacts.isEmpty()) { - Row(verticalAlignment = Alignment.CenterVertically) { - CircularProgressIndicator(modifier = Modifier.size(20.dp), strokeWidth = 2.dp) - Spacer(modifier = Modifier.width(8.dp)) - Text( - stringResource(id = R.string.app_card_loading_artifacts), - style = MaterialTheme.typography.bodyMedium, - ) - } - } else if (job.artifacts.isEmpty() && viewModel.isLoadingJobArtifacts[job.taskId] == false) { - Text( - stringResource(id = R.string.app_card_no_apks_found), - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(top = 8.dp), - ) - } else { - if (supportedArtifacts.isNotEmpty()) { - Text( - text = stringResource(id = R.string.app_card_supported_apks_title), - style = MaterialTheme.typography.titleMedium, - modifier = Modifier.padding(bottom = 8.dp), - ) - Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { - supportedArtifacts.forEach { artifactUiModel -> - DisplayArtifactCard( - artifact = artifactUiModel, - viewModel = viewModel, - ) - } - } - } - - if (unsupportedArtifacts.isNotEmpty()) { - var isExpanded by remember { mutableStateOf(false) } - val topPadding = if (supportedArtifacts.isNotEmpty()) 12.dp else 0.dp - Spacer(modifier = Modifier.padding(top = topPadding)) - - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { isExpanded = !isExpanded } - .padding(vertical = 8.dp), - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.SpaceBetween, - ) { - Text( - text = stringResource(id = R.string.app_card_unsupported_apks_title, unsupportedArtifacts.size), - style = MaterialTheme.typography.titleMedium, - ) - Icon( - imageVector = if (isExpanded) Icons.Filled.ArrowDropUp else Icons.Filled.ArrowDropDown, - contentDescription = if (isExpanded) stringResource(id = R.string.app_card_collapse_description) else stringResource(id = R.string.app_card_expand_description), - ) - } - if (isExpanded) { - Column( - verticalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(top = 8.dp), - ) { - unsupportedArtifacts.forEach { artifactUiModel -> - DisplayArtifactCard( - artifact = artifactUiModel, - viewModel = viewModel, - ) - } - } - } - } - } - } - } -} - -@Composable -private fun DisplayArtifactCard( - artifact: ArtifactUiModel, - viewModel: TryFoxViewModel, -) { - if (artifact.downloadState is DownloadState.DownloadFailed) { - val rawErrorMessage = (artifact.downloadState as DownloadState.DownloadFailed).message - val displayErrorMessage = rawErrorMessage ?: stringResource(id = R.string.common_unknown_error) - ErrorState(errorMessage = stringResource(R.string.app_card_download_failed_message, displayErrorMessage)) - Spacer(modifier = Modifier.padding(top = 4.dp)) - } - - ArtifactCard( - downloadState = artifact.downloadState, - abi = artifact.abi, - onDownloadClick = { - viewModel.downloadArtifact(artifact) - }, - onInstallClick = { viewModel.installApk(it) }, - ) -} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt index d6ced33..5497ddf 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/AppIcon.kt @@ -1,42 +1,91 @@ package org.mozilla.tryfox.ui.composables import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.draw.scale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_NIGHTLY import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_DEBUG +import org.mozilla.tryfox.util.FOCUS_NIGHTLY import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER +private const val PADDED_FOREGROUND_ICON_SCALE = 1.8f + @Composable -fun AppIcon(appName: String, modifier: Modifier = Modifier) { - val (iconResId, contentDescResId) = when { - appName == REFERENCE_BROWSER -> R.drawable.ic_reference_browser to R.string.app_icon_reference_browser_description - appName == FENIX -> R.drawable.ic_fenix_nightly to R.string.app_icon_firefox_nightly_description - appName == FENIX_BETA -> R.drawable.ic_firefox_beta to R.string.app_icon_firefox_description - appName == FENIX_RELEASE -> R.drawable.ic_firefox to R.string.app_icon_firefox_description - appName == FOCUS -> R.drawable.ic_focus to R.string.app_icon_focus_description - appName == FOCUS_RELEASE -> R.drawable.ic_focus to R.string.app_icon_focus_description - else -> { - println("Titouan - Error - $appName") - null to null +fun AppIcon( + appName: String, + modifier: Modifier = Modifier, + useSearchResultVariant: Boolean = false, +) { + val (iconResId, contentDescResId) = appIconResources(appName, useSearchResultVariant) + val isPaddedForeground = appName in setOf(FENIX_DEBUG, FOCUS_NIGHTLY, FOCUS_BETA) || + (appName == FOCUS && !useSearchResultVariant) || + (useSearchResultVariant && appName in setOf(FENIX, FENIX_NIGHTLY, FENIX_BETA, FOCUS_BETA, FOCUS_NIGHTLY)) + if (isPaddedForeground) { + Box(modifier = modifier.clipToBounds()) { + Image( + painter = painterResource(id = iconResId), + contentDescription = stringResource(id = contentDescResId), + modifier = Modifier.fillMaxSize().scale(PADDED_FOREGROUND_ICON_SCALE), + ) } - } - - if (iconResId != null && contentDescResId != null) { + } else { Image( painter = painterResource(id = iconResId), contentDescription = stringResource(id = contentDescResId), modifier = modifier, ) - Spacer(modifier = Modifier.width(8.dp)) } + Spacer(modifier = Modifier.width(8.dp)) } + +internal fun appIconResources( + appName: String, + useSearchResultVariant: Boolean, +): Pair = + when { + appName == REFERENCE_BROWSER -> R.drawable.ic_reference_browser to R.string.app_icon_reference_browser_description + appName == FENIX -> { + (if (useSearchResultVariant) R.drawable.ic_fenix_debug_foreground else R.drawable.ic_fenix_nightly) to + R.string.app_icon_firefox_nightly_description + } + appName == FENIX_NIGHTLY -> { + (if (useSearchResultVariant) R.drawable.ic_fenix_nightly_foreground else R.drawable.ic_fenix_nightly) to + R.string.app_icon_firefox_nightly_description + } + appName == FENIX_BETA -> { + (if (useSearchResultVariant) R.drawable.ic_fenix_beta_foreground else R.drawable.ic_firefox_beta) to + R.string.app_icon_firefox_description + } + appName == FENIX_DEBUG -> R.drawable.ic_fenix_debug_foreground to R.string.app_icon_firefox_description + appName == FOCUS_DEBUG -> R.drawable.ic_focus_debug_foreground_v2 to R.string.app_icon_focus_description + appName == FENIX_RELEASE -> R.drawable.ic_firefox to R.string.app_icon_firefox_description + appName == FOCUS -> { + (if (useSearchResultVariant) R.drawable.ic_focus_debug_foreground_v2 else R.drawable.ic_focus_nightly_foreground) to + R.string.app_icon_focus_description + } + appName == FOCUS_NIGHTLY -> { + R.drawable.ic_focus_nightly_foreground to R.string.app_icon_focus_description + } + appName == FOCUS_BETA -> { + R.drawable.ic_focus_beta_foreground to R.string.app_icon_focus_description + } + appName == FOCUS_RELEASE -> R.drawable.ic_focus to R.string.app_icon_focus_description + else -> R.drawable.unknown_app to R.string.app_icon_generic_description + } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt index 3975c81..faabbfe 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt @@ -65,6 +65,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.atStartOfDayIn import kotlinx.datetime.toLocalDateTime import org.mozilla.tryfox.R +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.NightlyBuildOption @@ -72,11 +73,11 @@ import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.FeatureFlags import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.parseDateToLocalDate -import java.io.File private object ArchiveGroupCardTokens { val CardPaddingTop = 4.dp @@ -93,7 +94,7 @@ fun ArchiveGroupCard( modifier: Modifier = Modifier, apks: List, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, onOpenAppClick: () -> Unit, onUninstallClick: () -> Unit, appState: AppState?, @@ -110,6 +111,8 @@ fun ArchiveGroupCard( pendingBuildOptions: List = emptyList(), onBuildSelected: (String) -> Unit = {}, onDismissBuildPicker: () -> Unit = {}, + installStates: Map = emptyMap(), + onOpenInstalledApp: (String) -> Unit = {}, ) { if (pendingBuildOptions.isNotEmpty()) { NightlyBuildPickerDialog( @@ -130,7 +133,7 @@ fun ArchiveGroupCard( val dateFromApk = firstApk?.date ?: "" // Release, beta and Focus release all pick a specific version from a dropdown. val hasReleaseVersionPicker = - appName == FENIX_RELEASE || appName == FOCUS_RELEASE || appName == FENIX_BETA + appName == FENIX_RELEASE || appName == FOCUS_RELEASE || appName == FENIX_BETA || appName == FOCUS_BETA val isDatePickerEnabled = appName != REFERENCE_BROWSER && !hasReleaseVersionPicker Column(modifier = Modifier.padding(ArchiveGroupCardTokens.ColumnPadding)) { @@ -184,6 +187,8 @@ fun ArchiveGroupCard( onInstallClick, onUninstallClick, appState, + installStates, + onOpenInstalledApp, ) } @@ -433,9 +438,11 @@ private fun ReleaseVersionSelector( private fun ArchiveGroupAbiSelector( apks: List, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, onUninstallClick: () -> Unit, appState: AppState?, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, ) { val firstSupportedIndex = apks.indexOfFirst { it.abi.isSupported }.takeIf { it != -1 } ?: 0 var selectedIndex by remember { mutableStateOf(firstSupportedIndex) } @@ -486,6 +493,8 @@ private fun ArchiveGroupAbiSelector( Spacer(Modifier.height(ArchiveGroupCardTokens.SpacerHeight)) } + val selectedApk = apks[selectedIndex] + val installState = installStates[selectedApk.uniqueKey] ?: InstallState.Idle Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) { if (appState?.isInstalled == true) { Button( @@ -499,11 +508,21 @@ private fun ArchiveGroupAbiSelector( } } - val selectedApk = apks[selectedIndex] DownloadButton( downloadState = selectedApk.downloadState, onDownloadClick = { onDownloadClick(selectedApk) }, - onInstallClick = { file -> onInstallClick(file) }, + onInstallClick = { onInstallClick(selectedApk) }, + installState = installState, + onOpenClick = onOpenInstalledApp, + debugLabel = "home:${selectedApk.uniqueKey}", + ) + } + (installState as? InstallState.Failed)?.let { failure -> + Text( + text = failure.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(top = 8.dp), ) } } @@ -553,6 +572,7 @@ private fun getFriendlyAppName(appName: String): String = FENIX_RELEASE -> stringResource(R.string.app_name_fenix_release) FENIX_BETA -> stringResource(R.string.app_name_fenix_beta) FOCUS -> stringResource(id = R.string.app_name_focus) + FOCUS_BETA -> stringResource(id = R.string.app_name_focus_beta) FOCUS_RELEASE -> stringResource(id = R.string.app_name_focus_release) REFERENCE_BROWSER -> stringResource(R.string.app_name_reference_browser) else -> appName diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt index 8289613..0e1617c 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt @@ -84,6 +84,28 @@ fun CurrentInstallState( ) } + if (appState != null && appState.isInstalled && appState.isFromTryFox) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_tryfox_black), + contentDescription = stringResource(id = R.string.app_icon_tryfox_description), + modifier = Modifier + .size(24.dp) + .clickable { showMetadataSheet = true }, + ) + } + + if (appState != null && appState.isSideloaded) { + Spacer(modifier = Modifier.width(8.dp)) + Icon( + painter = painterResource(id = R.drawable.ic_usb_c_cable), + contentDescription = stringResource(id = R.string.install_metadata_source_sideloaded), + modifier = Modifier + .size(24.dp) + .clickable { showMetadataSheet = true }, + ) + } + if (appState != null && appState.isInstalled) { Spacer(modifier = Modifier.width(8.dp)) Text( diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/DownloadButton.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/DownloadButton.kt index 4d89c3d..6e82b2f 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/DownloadButton.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/DownloadButton.kt @@ -1,68 +1,87 @@ package org.mozilla.tryfox.ui.composables -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.size -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.Text +import android.util.Log +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.testTag // Added import import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.install.InstallState import java.io.File +private const val TAG = "DownloadButton" + +@Suppress("LongParameterList", "CyclomaticComplexMethod") @Composable fun DownloadButton( downloadState: DownloadState, onDownloadClick: () -> Unit, onInstallClick: (File) -> Unit, + modifier: Modifier = Modifier, + inProgressText: String? = null, + determinateProgressAnimation: DeterminateProgressAnimation = DeterminateProgressAnimation.Rotating, + installState: InstallState = InstallState.Idle, + installDisabled: Boolean = false, + onOpenClick: ((String) -> Unit)? = null, + debugLabel: String = "action_button", ) { - when (downloadState) { - is DownloadState.Downloaded -> { - Button( - onClick = { onInstallClick(downloadState.file) }, - modifier = Modifier.testTag("action_button_install_ready"), // Tag for Install state - ) { - Text(stringResource(id = R.string.download_button_install)) - } - } - is DownloadState.InProgress -> { - Button( - onClick = {}, - enabled = false, - modifier = Modifier.testTag("action_button_downloading"), // Tag for Downloading state - ) { - if (downloadState.isIndeterminate) { - CircularProgressIndicator( - modifier = Modifier - .size(ButtonDefaults.IconSize) - .testTag("progress_indicator_indeterminate"), // Tag for indeterminate progress - strokeWidth = 2.dp, - ) - } else { - CircularProgressIndicator( - progress = { downloadState.progress }, - modifier = Modifier - .size(ButtonDefaults.IconSize) - .testTag("progress_indicator_determinate"), // Tag for determinate progress - strokeWidth = 2.dp, - ) - } - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(id = R.string.download_button_downloading)) - } - } - is DownloadState.NotDownloaded, is DownloadState.DownloadFailed -> { - Button( - onClick = onDownloadClick, - modifier = Modifier.testTag("action_button_download_initial"), // Tag for Download state - ) { - Text(stringResource(id = R.string.download_button_download)) - } - } + val inProgressState = downloadState as? DownloadState.InProgress + val downloadedState = downloadState as? DownloadState.Downloaded + val defaultText = stringResource(id = R.string.download_button_downloading) + val colorScheme = MaterialTheme.colorScheme + val isInstalling = installState is InstallState.Installing || installState is InstallState.Uninstalling + val isInstalled = installState is InstallState.Installed + val isDownloading = inProgressState != null + + LaunchedEffect(downloadState, installState, installDisabled, debugLabel) { + Log.d( + TAG, + "[$debugLabel] state download=${downloadState.javaClass.simpleName} install=${installState.javaClass.simpleName} " + + "installDisabled=$installDisabled", + ) } + + ProgressButton( + onClick = { + (installState as? InstallState.Installed)?.let { installed -> + onOpenClick?.invoke(installed.packageName) + } ?: downloadedState?.let { onInstallClick(it.file) } ?: onDownloadClick() + }, + enabled = !installDisabled, + isLoading = isDownloading || isInstalling, + progress = if (isInstalling) null else inProgressState + ?.progress + ?.takeUnless { inProgressState.isIndeterminate }, + text = if (isInstalled) { + stringResource(id = R.string.download_button_open) + } else if (downloadedState == null) { + stringResource(id = R.string.download_button_download) + } else { + stringResource(id = R.string.download_button_install) + }, + loadingText = if (isInstalling || isInstalled) { + stringResource(id = R.string.download_button_installing) + } else { + inProgressText ?: defaultText + }, + determinateProgressAnimation = determinateProgressAnimation, + // Keep the fill stable across every state; the lighter progress ring is + // deliberately distinct from the primary button background. + trackColor = colorScheme.onPrimary.copy(alpha = 0.28f), + indicatorColor = colorScheme.primaryContainer, + trackEndColor = colorScheme.primaryContainer, + endingAnimation = EndingAnimation.None, + containerColor = colorScheme.primary, + contentColor = colorScheme.onPrimary, + modifier = modifier, + semanticsTag = when { + isInstalled -> "action_button_installed" + isInstalling -> "action_button_installing" + downloadedState != null -> "action_button_install_ready" + inProgressState != null -> "action_button_downloading" + else -> "action_button_download_initial" + }, + ) } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/EndingAnimation.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/EndingAnimation.kt new file mode 100644 index 0000000..9dcccf3 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/EndingAnimation.kt @@ -0,0 +1,15 @@ +package org.mozilla.tryfox.ui.composables + +sealed class EndingAnimation { + data object None : EndingAnimation() + + data class Pulse( + val beatDuration: Float, + val beats: Int, + val delayBetweenBeats: Float, + ) : EndingAnimation() + + data class Constant( + val duration: Float, + ) : EndingAnimation() +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButton.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButton.kt new file mode 100644 index 0000000..4cf21cf --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButton.kt @@ -0,0 +1,504 @@ +package org.mozilla.tryfox.ui.composables + +import android.util.Log +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector4D +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.TwoWayConverter +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.defaultMinSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Outline +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.graphics.PathMeasure +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.ProgressBarRangeInfo +import androidx.compose.ui.semantics.progressBarRangeInfo +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.testTag +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.text.rememberTextMeasurer +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.delay +import kotlin.math.roundToInt + +private const val TAG = "ProgressButton" + +@Composable +fun ProgressButton( + text: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, + isLoading: Boolean, + progress: Float? = null, + loadingText: String = text, + determinateProgressAnimation: DeterminateProgressAnimation = DeterminateProgressAnimation.Static, + segmentLengthFraction: Float = 0.18f, + enabled: Boolean = true, + trackColor: Color = MaterialTheme.colorScheme.primary.copy(alpha = 0.22f), + indicatorColor: Color = MaterialTheme.colorScheme.primary, + trackEndColor: Color = MaterialTheme.colorScheme.primary, + containerColor: Color = MaterialTheme.colorScheme.primary, + contentColor: Color = MaterialTheme.colorScheme.onPrimary, + disabledContainerColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.12f), + disabledContentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f), + shape: Shape = ButtonDefaults.shape, + strokeWidth: Dp = 6.dp, + fullCycleMillis: Int = 1800, + ignoreClicksDuringClosingAnimation: Boolean = true, + completionSweepMillis: Float = 500f, + endingAnimation: EndingAnimation = EndingAnimation.Constant(duration = 1000f), + semanticsTag: String = PROGRESS_BUTTON_TAG, +) { + val baseSegmentFraction = segmentLengthFraction.coerceIn(0f, 1f) + val clampedProgress = progress?.coerceIn(0f, 1f) + val isActiveLoading = isLoading + val resolvedContainerColor = if (enabled) containerColor else disabledContainerColor + val resolvedContentColor = if (enabled) contentColor else disabledContentColor + + val infiniteTransition = rememberInfiniteTransition(label = "borderTransition") + val animatedFraction by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = fullCycleMillis, easing = LinearEasing), + repeatMode = RepeatMode.Restart, + ), + label = "borderSweep", + ) + + var isCompleting by remember { mutableStateOf(false) } + val completionSegment = remember { Animatable(initialValue = 0f) } + val determinateProgress = remember { Animatable(initialValue = 0f) } + val borderAlpha = remember { Animatable(initialValue = if (isActiveLoading) 1f else 0f) } + val indicatorColorAnim = remember { + Animatable( + initialValue = indicatorColor, + typeConverter = ColorVectorConverter, + ) + } + var previousActiveLoading by remember { mutableStateOf(isActiveLoading) } + var lastProgressValue by remember { mutableStateOf(clampedProgress) } + var completionRequest by remember { mutableStateOf(null) } + var completionWasDeterminate by remember { mutableStateOf(false) } + + LaunchedEffect(clampedProgress) { + clampedProgress ?: return@LaunchedEffect + Log.d( + TAG, + "[$semanticsTag] progress retarget: ${determinateProgress.value} -> $clampedProgress", + ) + determinateProgress.animateTo( + targetValue = clampedProgress, + animationSpec = spring( + dampingRatio = Spring.DampingRatioNoBouncy, + stiffness = Spring.StiffnessMedium, + ), + ) + } + + LaunchedEffect(isActiveLoading, clampedProgress, indicatorColor, baseSegmentFraction) { + val wasActive = previousActiveLoading + val previousProgress = lastProgressValue + previousActiveLoading = isActiveLoading + lastProgressValue = clampedProgress + Log.d( + TAG, + "[$semanticsTag] state effect: wasActive=$wasActive isActiveLoading=$isActiveLoading " + + "previousProgress=$previousProgress clampedProgress=$clampedProgress isCompleting=$isCompleting", + ) + + if (isActiveLoading) { + completionRequest = null + if (isCompleting) { + completionSegment.stop() + isCompleting = false + } + indicatorColorAnim.stop() + indicatorColorAnim.snapTo(indicatorColor) + if (!wasActive || borderAlpha.value < 1f) { + borderAlpha.stop() + borderAlpha.animateTo( + targetValue = 1f, + animationSpec = tween(durationMillis = 180, easing = LinearEasing), + ) + } + } else { + if (wasActive) { + val initialFraction = if (previousProgress == null) { + baseSegmentFraction + } else { + determinateProgress.value + }.coerceIn(0f, 1f) + Log.d( + TAG, + "[$semanticsTag] loading ended -> requesting completion sweep " + + "from initialFraction=$initialFraction wasDeterminate=${previousProgress != null}", + ) + completionRequest = CompletionParams( + initialFraction = initialFraction, + wasDeterminate = previousProgress != null, + ) + } else if (!isCompleting) { + indicatorColorAnim.stop() + indicatorColorAnim.snapTo(indicatorColor) + if (borderAlpha.value != 0f) { + borderAlpha.stop() + borderAlpha.snapTo(0f) + } + } + } + } + + LaunchedEffect(completionRequest, trackEndColor, completionSweepMillis, indicatorColor, endingAnimation) { + val request = completionRequest ?: return@LaunchedEffect + + isCompleting = true + completionWasDeterminate = request.wasDeterminate + Log.d( + TAG, + "[$semanticsTag] completion started: initialFraction=${request.initialFraction} " + + "wasDeterminate=${request.wasDeterminate}", + ) + + completionSegment.stop() + completionSegment.snapTo(request.initialFraction) + borderAlpha.stop() + borderAlpha.snapTo(1f) + + val sweepDuration = completionSweepMillis.coerceAtLeast(0f).roundToInt().coerceAtLeast(1) + + try { + if (request.initialFraction < 1f && sweepDuration > 0) { + completionSegment.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = sweepDuration, + easing = LinearEasing, + ), + ) + } else { + completionSegment.snapTo(1f) + } + Log.d(TAG, "[$semanticsTag] completion sweep reached full border") + + if (indicatorColorAnim.value != trackEndColor) { + indicatorColorAnim.animateTo( + targetValue = trackEndColor, + animationSpec = tween(durationMillis = 300, easing = LinearEasing), + ) + } + + when (endingAnimation) { + EndingAnimation.None -> { + borderAlpha.snapTo(0f) + } + is EndingAnimation.Constant -> { + val delayMillis = endingAnimation.duration.coerceAtLeast(0f).roundToInt() + if (delayMillis > 0) { + delay(delayMillis.toLong()) + } + borderAlpha.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 500, easing = LinearEasing), + ) + } + is EndingAnimation.Pulse -> { + val beatCount = endingAnimation.beats.coerceAtLeast(0) + val beatDuration = endingAnimation.beatDuration.coerceAtLeast(0f) + val halfDuration = (beatDuration / 2f).coerceAtLeast(1f) + val delayBetween = endingAnimation.delayBetweenBeats.coerceAtLeast(0f) + if (delayBetween > 0f) { + delay(delayBetween.roundToInt().toLong()) + } + repeat(beatCount) { beatIndex -> + borderAlpha.animateTo( + targetValue = 0.7f, + animationSpec = tween( + durationMillis = halfDuration.roundToInt(), + easing = LinearEasing, + ), + ) + borderAlpha.animateTo( + targetValue = 1f, + animationSpec = tween( + durationMillis = halfDuration.roundToInt(), + easing = LinearEasing, + ), + ) + if (delayBetween > 0f && beatIndex != beatCount - 1) { + delay(delayBetween.roundToInt().toLong()) + } + } + if (delayBetween > 0f) { + delay(delayBetween.roundToInt().toLong()) + } + borderAlpha.animateTo( + targetValue = 0f, + animationSpec = tween(durationMillis = 500, easing = LinearEasing), + ) + } + } + } finally { + completionSegment.snapTo(0f) + if (!borderAlpha.isRunning) { + borderAlpha.snapTo(0f) + } + indicatorColorAnim.stop() + indicatorColorAnim.snapTo(indicatorColor) + completionWasDeterminate = false + isCompleting = false + completionRequest = null + Log.d(TAG, "[$semanticsTag] completion finished, back to idle") + } + } + + val showLoadingText = isActiveLoading || isCompleting || borderAlpha.value > 0.01f + + val progressFractionValue = when { + isCompleting -> completionSegment.value + clampedProgress != null -> determinateProgress.value + else -> baseSegmentFraction + }.coerceIn(0f, 1f) + + val progressRangeInfo = if (clampedProgress != null || isCompleting) { + ProgressBarRangeInfo(progressFractionValue, 0f..1f) + } else { + ProgressBarRangeInfo.Indeterminate + } + + val textStyle = MaterialTheme.typography.labelLarge + val textMeasurer = rememberTextMeasurer() + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val horizontalPadding = remember(layoutDirection) { + ButtonDefaults.ContentPadding.calculateLeftPadding(layoutDirection) + + ButtonDefaults.ContentPadding.calculateRightPadding(layoutDirection) + } + val idleTextWidthPx = remember(text, textStyle) { + textMeasurer.measure( + text = AnnotatedString(text), + style = textStyle, + ).size.width.toFloat() + } + val loadingTextWidthPx = remember(loadingText, textStyle) { + textMeasurer.measure( + text = AnnotatedString(loadingText), + style = textStyle, + ).size.width.toFloat() + } + val targetWidth = remember(showLoadingText, idleTextWidthPx, loadingTextWidthPx, horizontalPadding, density) { + val contentWidthPx = if (showLoadingText) loadingTextWidthPx else idleTextWidthPx + val contentWidthDp = with(density) { contentWidthPx.toDp() } + (contentWidthDp + horizontalPadding).coerceAtLeast(ButtonDefaults.MinWidth) + } + val animatedWidthPx by animateFloatAsState( + targetValue = with(density) { targetWidth.toPx() }, + animationSpec = tween(durationMillis = 500, easing = FastOutSlowInEasing), + label = "buttonWidth", + ) + val animatedWidth = with(density) { animatedWidthPx.toDp() } + + Surface( + onClick = { + if (isCompleting && ignoreClicksDuringClosingAnimation) return@Surface + onClick() + }, + enabled = enabled, + shape = shape, + color = resolvedContainerColor, + contentColor = resolvedContentColor, + modifier = modifier + .width(animatedWidth) + .semantics { + testTag = semanticsTag + progressFractionSemantics = progressFractionValue + borderAlphaSemantics = borderAlpha.value + indicatorColorSemantics = indicatorColorAnim.value + isCompletingSemantics = isCompleting + progressStartFractionSemantics = progressStartFraction( + isCompleting = isCompleting, + completionWasDeterminate = completionWasDeterminate, + hasDeterminateProgress = clampedProgress != null, + determinateProgressAnimation = determinateProgressAnimation, + spinnerFraction = animatedFraction, + ) + progressBarRangeInfo = progressRangeInfo + } + .animateContentSize( + animationSpec = tween(durationMillis = 250, easing = LinearEasing), + alignment = Alignment.Center, + ), + ) { + Box( + modifier = Modifier + .defaultMinSize( + minWidth = ButtonDefaults.MinWidth, + minHeight = ButtonDefaults.MinHeight, + ) + .drawWithContent { + drawContent() + + val strokeWidthPx = strokeWidth.toPx() + if (size.width <= 0f || size.height <= 0f) { + return@drawWithContent + } + + val borderOutline = shape.createOutline(size, layoutDirection, this) + if (borderOutline !is Outline.Rounded) { + return@drawWithContent + } + val borderPath = Path().apply { + addRoundRect( + roundRect = borderOutline.roundRect, + direction = Path.Direction.Clockwise, + ) + } + + val pathMeasure = PathMeasure().apply { setPath(borderPath, true) } + val pathLength = pathMeasure.length + if (pathLength <= 0f) return@drawWithContent + + val alpha = borderAlpha.value + if (alpha <= 0f) return@drawWithContent + + val shouldRender = isActiveLoading || isCompleting || alpha > 0f + if (!shouldRender) return@drawWithContent + + val activeSegmentFraction = progressFractionValue + if (activeSegmentFraction <= 0f) return@drawWithContent + + val normalizedSpinnerFraction = run { + val normalized = animatedFraction % 1f + if (normalized < 0f) normalized + 1f else normalized + } + val normalizedStartFraction = progressStartFraction( + isCompleting = isCompleting, + completionWasDeterminate = completionWasDeterminate, + hasDeterminateProgress = clampedProgress != null, + determinateProgressAnimation = determinateProgressAnimation, + spinnerFraction = normalizedSpinnerFraction, + ) + val startDistance = normalizedStartFraction * pathLength + val endDistance = startDistance + activeSegmentFraction * pathLength + + val currentTrackColor = trackColor + val currentIndicatorColor = indicatorColorAnim.value + + drawPath( + path = borderPath, + color = currentTrackColor.copy(alpha = currentTrackColor.alpha * alpha), + style = Stroke(width = strokeWidthPx, cap = StrokeCap.Butt), + ) + + val indicatorPath = Path() + pathMeasure.getSegment( + startDistance, + endDistance.coerceAtMost(pathLength), + indicatorPath, + true, + ) + if (endDistance > pathLength) { + val wrapPath = Path() + pathMeasure.getSegment(0f, endDistance - pathLength, wrapPath, true) + indicatorPath.addPath(wrapPath) + } + + drawPath( + path = indicatorPath, + color = currentIndicatorColor.copy(alpha = currentIndicatorColor.alpha * alpha), + style = Stroke(width = strokeWidthPx, cap = StrokeCap.Round), + ) + }, + contentAlignment = Alignment.Center, + ) { + Row( + modifier = Modifier.padding(ButtonDefaults.ContentPadding), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = if (showLoadingText) loadingText else text, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + softWrap = false, + overflow = TextOverflow.Clip, + ) + } + } + } +} + +/** Defines how a determinate progress arc moves around the button border. */ +enum class DeterminateProgressAnimation { + /** The progress arc begins at the same fixed point on the border. */ + Static, + + /** The progress arc rotates continuously while its length follows the current progress. */ + Rotating, +} + +private fun progressStartFraction( + isCompleting: Boolean, + completionWasDeterminate: Boolean, + hasDeterminateProgress: Boolean, + determinateProgressAnimation: DeterminateProgressAnimation, + spinnerFraction: Float, +): Float { + if ( + isCompleting && + completionWasDeterminate && + determinateProgressAnimation == DeterminateProgressAnimation.Static + ) { + return 0f + } + if (hasDeterminateProgress && determinateProgressAnimation == DeterminateProgressAnimation.Static) return 0f + return spinnerFraction % 1f +} + +private data class CompletionParams( + val initialFraction: Float, + val wasDeterminate: Boolean, +) + +private val ColorVectorConverter = TwoWayConverter( + convertToVector = { color -> + AnimationVector4D(color.red, color.green, color.blue, color.alpha) + }, + convertFromVector = { vector -> + Color(vector.v1, vector.v2, vector.v3, vector.v4) + }, +) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButtonSemantics.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButtonSemantics.kt new file mode 100644 index 0000000..f849cf7 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProgressButtonSemantics.kt @@ -0,0 +1,22 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.semantics.SemanticsPropertyKey +import androidx.compose.ui.semantics.SemanticsPropertyReceiver + +const val PROGRESS_BUTTON_TAG = "IndeterminateProgressButton" + +val ProgressFractionKey = SemanticsPropertyKey("ProgressFraction") +var SemanticsPropertyReceiver.progressFractionSemantics by ProgressFractionKey + +val BorderAlphaKey = SemanticsPropertyKey("BorderAlpha") +var SemanticsPropertyReceiver.borderAlphaSemantics by BorderAlphaKey + +val IndicatorColorKey = SemanticsPropertyKey("IndicatorColor") +var SemanticsPropertyReceiver.indicatorColorSemantics by IndicatorColorKey + +val IsCompletingKey = SemanticsPropertyKey("IsCompleting") +var SemanticsPropertyReceiver.isCompletingSemantics by IsCompletingKey + +val ProgressStartFractionKey = SemanticsPropertyKey("ProgressStartFraction") +var SemanticsPropertyReceiver.progressStartFractionSemantics by ProgressStartFractionKey diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt new file mode 100644 index 0000000..9a231a2 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ProjectSelector.kt @@ -0,0 +1,88 @@ +package org.mozilla.tryfox.ui.composables + +import androidx.compose.animation.core.animateDpAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.background +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp + +@Composable +fun ProjectSelector( + projects: List, + selectedProject: String, + projectLabel: (String) -> String, + onProjectSelected: (String) -> Unit, + enabled: Boolean = true, + modifier: Modifier = Modifier, +) { + val selectedIndex = projects.indexOf(selectedProject).coerceAtLeast(0) + val selectedContainerColor = if (isSystemInDarkTheme()) { + MaterialTheme.colorScheme.primaryContainer + } else { + MaterialTheme.colorScheme.primary.copy(alpha = 0.12f) + } + BoxWithConstraints( + modifier = modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.surfaceVariant, RoundedCornerShape(24.dp)) + .padding(2.dp) + .testTag("unified_search_project_input"), + ) { + val segmentWidth = maxWidth / projects.size + val indicatorOffset by animateDpAsState( + targetValue = segmentWidth * selectedIndex, + animationSpec = tween(durationMillis = 220), + label = "project selector indicator offset", + ) + + Box( + modifier = Modifier + .offset(x = indicatorOffset) + .width(segmentWidth) + .fillMaxHeight() + .background(selectedContainerColor, RoundedCornerShape(20.dp)), + ) + Row(modifier = Modifier.fillMaxWidth().fillMaxHeight()) { + projects.forEach { project -> + TextButton( + onClick = { onProjectSelected(project) }, + enabled = enabled, + modifier = Modifier + .width(segmentWidth) + .fillMaxHeight() + .testTag("unified_search_project_$project"), + shape = RoundedCornerShape(20.dp), + contentPadding = PaddingValues(horizontal = 2.dp), + colors = ButtonDefaults.textButtonColors( + contentColor = MaterialTheme.colorScheme.onSurfaceVariant, + ), + ) { + Text( + text = projectLabel(project), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + } + } + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/PushCommentCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/PushCommentCard.kt index 1580edb..dc764b8 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/PushCommentCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/PushCommentCard.kt @@ -22,6 +22,7 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag // Added import +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.LinkAnnotation import androidx.compose.ui.text.SpanStyle import androidx.compose.ui.text.TextLinkStyles @@ -40,6 +41,7 @@ import kotlinx.datetime.format.FormatStringsInDatetimeFormats import kotlinx.datetime.format.byUnicodePattern import kotlinx.datetime.toLocalDateTime import org.mozilla.tryfox.ui.theme.TryFoxTheme +import org.mozilla.tryfox.util.withoutTrailingReviewerDirective import java.util.regex.Pattern // Helper data class to store link information @@ -50,87 +52,68 @@ private data class LinkableSpan( val url: String, ) -@OptIn(FormatStringsInDatetimeFormats::class) @Composable -fun PushCommentCard( - title: String? = null, - comment: String, - author: String?, - revision: String, - pushTimestamp: Long, -) { - val urlPattern = remember { - Pattern.compile( - "(https?://|www\\.)" + // Scheme or www. - "([\\da-zA-Z.-]+)" + // Domain name - "(\\.[a-zA-Z.]{2,6})" + // TLD - "([/\\w .-]*)*/?", // Path and query +fun rememberLinkedPushComment(comment: String): AnnotatedString { + val linkColor = MaterialTheme.colorScheme.primary + return remember(comment, linkColor) { + val urlPattern = Pattern.compile( + "(https?://|www\\.)" + + "([\\da-zA-Z.-]+)" + + "(\\.[a-zA-Z.]{2,6})" + + "([/\\w .-]*)*/?", ) - } - val bugPattern = remember { - Pattern.compile("Bug\\s*(\\d+)", Pattern.CASE_INSENSITIVE) - } - - val linkSpans = remember(comment) { - // Recalculate if comment changes + val bugPattern = Pattern.compile("Bug\\s*(\\d+)", Pattern.CASE_INSENSITIVE) val spans = mutableListOf() - val urlMatcher = urlPattern.matcher(comment) while (urlMatcher.find()) { - spans.add( - LinkableSpan( - start = urlMatcher.start(), - end = urlMatcher.end(), - displayText = urlMatcher.group(0) ?: "", - url = urlMatcher.group(0) ?: "", - ), - ) + spans += LinkableSpan(urlMatcher.start(), urlMatcher.end(), urlMatcher.group(0) ?: "", urlMatcher.group(0) ?: "") } - val bugMatcher = bugPattern.matcher(comment) while (bugMatcher.find()) { - val bugNumber = bugMatcher.group(1) - if (bugNumber != null) { - spans.add( - LinkableSpan( - start = bugMatcher.start(), - end = bugMatcher.end(), - displayText = bugMatcher.group(0) ?: "", - url = "https://bugzilla.mozilla.org/show_bug.cgi?id=$bugNumber", - ), + bugMatcher.group(1)?.let { bugNumber -> + spans += LinkableSpan( + bugMatcher.start(), + bugMatcher.end(), + bugMatcher.group(0) ?: "", + "https://bugzilla.mozilla.org/show_bug.cgi?id=$bugNumber", ) } } spans.sortBy { it.start } - spans - } - - val annotatedString = buildAnnotatedString { - var lastMatchEnd = 0 - linkSpans.forEach { span -> - if (span.start > lastMatchEnd) { - append(comment.substring(lastMatchEnd, span.start)) - } - withLink( - link = LinkAnnotation.Url( - url = span.url, - styles = TextLinkStyles( - style = SpanStyle( - color = MaterialTheme.colorScheme.primary, - fontWeight = FontWeight.Bold, - textDecoration = TextDecoration.Underline, + buildAnnotatedString { + var lastMatchEnd = 0 + spans.forEach { span -> + if (span.start > lastMatchEnd) append(comment.substring(lastMatchEnd, span.start)) + withLink( + LinkAnnotation.Url( + span.url, + TextLinkStyles( + style = SpanStyle( + color = linkColor, + fontWeight = FontWeight.Bold, + textDecoration = TextDecoration.Underline, + ), + ), ), - ), - ), - ) { - append(span.displayText) + ) { append(span.displayText) } + lastMatchEnd = span.end } - lastMatchEnd = span.end - } - if (lastMatchEnd < comment.length) { - append(comment.substring(lastMatchEnd)) + if (lastMatchEnd < comment.length) append(comment.substring(lastMatchEnd)) } } +} + +@OptIn(FormatStringsInDatetimeFormats::class) +@Composable +fun PushCommentCard( + title: String? = null, + comment: String, + author: String?, + revision: String, + pushTimestamp: Long, +) { + val displayComment = remember(comment) { comment.withoutTrailingReviewerDirective() } + val annotatedString = rememberLinkedPushComment(displayComment) Card( modifier = Modifier diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt index 0e889f7..4b47caa 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/TryFoxCard.kt @@ -17,19 +17,20 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import org.mozilla.tryfox.R -import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.ui.theme.customColors -import java.io.File @Composable fun TryFoxCard( modifier: Modifier = Modifier, app: AppUiModel, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, ) { val latestApk = (app.apks as? ApksResult.Success)?.apks?.firstOrNull() ?: return @@ -39,13 +40,15 @@ fun TryFoxCard( containerColor = MaterialTheme.customColors.tryFoxCardBackground, ), ) { - Row( + val installState = installStates[latestApk.uniqueKey] ?: InstallState.Idle + Column { + Row( modifier = Modifier .fillMaxWidth() .padding(vertical = 8.dp, horizontal = 16.dp), horizontalArrangement = Arrangement.SpaceBetween, verticalAlignment = Alignment.CenterVertically, - ) { + ) { Column { Text( text = stringResource(id = R.string.tryfox_card_title, latestApk.version), @@ -53,14 +56,24 @@ fun TryFoxCard( ) } Spacer(modifier = Modifier.width(4.dp)) - DownloadButton( + DownloadButton( downloadState = latestApk.downloadState, onDownloadClick = { onDownloadClick(latestApk) }, - onInstallClick = { - val downloadedFile = (latestApk.downloadState as? DownloadState.Downloaded)?.file - downloadedFile?.let { onInstallClick(it) } - }, - ) + onInstallClick = { onInstallClick(latestApk) }, + inProgressText = stringResource(id = R.string.download_button_download), + installState = installState, + onOpenClick = onOpenInstalledApp, + debugLabel = "home:${latestApk.uniqueKey}", + ) + } + (installState as? InstallState.Failed)?.let { failure -> + Text( + text = failure.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), + ) + } } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt index 39b5f8f..dad8f7f 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.ui.models +import kotlinx.datetime.LocalDate import org.mozilla.tryfox.data.DownloadState import java.io.File @@ -14,4 +15,5 @@ data class ApkUiModel( var downloadState: DownloadState = DownloadState.NotDownloaded, val uniqueKey: String, // e.g., "appName/date(YYYY-MM-DD)/fileName" val apkDir: File, + val buildDate: LocalDate? = null, ) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt index eed0ea8..5bb2323 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.ui.models import kotlinx.datetime.LocalDate +import org.mozilla.tryfox.data.InstalledTryBuild import org.mozilla.tryfox.util.Version sealed class ApksResult { @@ -15,7 +16,7 @@ sealed class ApksResult { */ data class NightlyBuildOption( val id: String, // the build's full "yyyy-MM-dd-HH-mm-ss" timestamp; groups its ABI variants - val label: String, // full date + time for display, e.g. "2026-07-24 09:17:32" + val label: String, // date + time for display, e.g. "2026-07-24 09:17" ) data class AppUiModel( @@ -26,6 +27,7 @@ data class AppUiModel( val installedDate: String?, val installingPackageName: String? = null, val splitNames: List = emptyList(), + val installedTryBuild: InstalledTryBuild? = null, val apks: ApksResult, val userPickedDate: LocalDate? = null, val selectedReleaseVersion: String? = null, diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/PushUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/PushUiModel.kt index a2ec752..f901af5 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/PushUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/PushUiModel.kt @@ -1,9 +1,12 @@ package org.mozilla.tryfox.ui.models data class PushUiModel( + /** Treeherder project that produced this push and its artifacts. */ + val project: String, val pushComment: String, val author: String, val jobs: List, val revision: String?, val pushTimestamp: Long, + val unsignedJobs: List = emptyList(), ) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt index 33c66b5..d0ef293 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryScreen.kt @@ -57,6 +57,7 @@ import logcat.LogPriority import logcat.logcat import org.mozilla.tryfox.R import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.composables.AppIcon import org.mozilla.tryfox.ui.composables.DownloadButton import org.mozilla.tryfox.ui.composables.ErrorState @@ -77,6 +78,7 @@ fun HistoryScreen( historyViewModel: HistoryViewModel, ) { val historyItems by historyViewModel.historyItems.collectAsState() + val installStates by historyViewModel.installStates.collectAsState() val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(historyItems.size) { @@ -156,6 +158,8 @@ fun HistoryScreen( onRevisionClick = onNavigateToTreeherderRevision, onDownloadClick = { historyViewModel.download(historyItem) }, onInstallClick = { file -> historyViewModel.install(historyItem, file) }, + installState = installStates[historyItem.entry.uniqueKey] ?: InstallState.Idle, + onOpenClick = historyViewModel::openInstalledApp, onDeleteClick = { historyViewModel.delete(historyItem) }, ) } @@ -170,6 +174,8 @@ private fun HistoryCard( onRevisionClick: (project: String, revision: String) -> Unit, onDownloadClick: () -> Unit, onInstallClick: (java.io.File) -> Unit, + installState: InstallState, + onOpenClick: (String) -> Unit, onDeleteClick: () -> Unit, ) { val entry = historyItem.entry @@ -260,8 +266,15 @@ private fun HistoryCard( downloadState = historyItem.downloadState, onDownloadClick = onDownloadClick, onInstallClick = onInstallClick, + inProgressText = stringResource(id = R.string.download_button_download), + installState = installState, + onOpenClick = onOpenClick, ) } + + (installState as? InstallState.Failed)?.let { failure -> + ErrorState(errorMessage = failure.message) + } } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt index ffde9cf..664fb0b 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HistoryViewModel.kt @@ -3,9 +3,7 @@ package org.mozilla.tryfox.ui.screens import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher -import kotlinx.coroutines.CoroutineStart import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -17,21 +15,25 @@ import kotlinx.coroutines.launch import logcat.LogPriority import logcat.logcat import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.CacheManager -import org.mozilla.tryfox.data.managers.IntentManager -import org.mozilla.tryfox.data.repositories.DownloadFileRepository import org.mozilla.tryfox.data.repositories.HistoryRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.InstallState +import org.mozilla.tryfox.install.TryBuildProvenance import org.mozilla.tryfox.ui.models.HistoryItemUiModel import org.mozilla.tryfox.util.TREEHERDER import java.io.File class HistoryViewModel( private val historyRepository: HistoryRepository, - private val downloadFileRepository: DownloadFileRepository, + private val downloadCoordinator: ApkDownloadCoordinator, private val cacheManager: CacheManager, - private val intentManager: IntentManager, + private val installCoordinator: ApkInstallCoordinator, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val currentTimeMillisProvider: () -> Long = System::currentTimeMillis, ) : ViewModel() { @@ -41,21 +43,25 @@ class HistoryViewModel( } private val downloadStates = MutableStateFlow>(emptyMap()) - private val activeDownloads = MutableStateFlow>(emptyMap()) - private val canceledDownloads = MutableStateFlow>(emptyMap()) private val cacheRefreshEvents = MutableStateFlow(0) - private var nextDownloadGeneration = 0L private val _historyItems = MutableStateFlow>(emptyList()) val historyItems: StateFlow> = _historyItems.asStateFlow() + val installStates: StateFlow> = installCoordinator.states init { logcat(LogPriority.DEBUG, TAG) { "init" } - viewModelScope.launch { + viewModelScope.launch(ioDispatcher) { historyRepository.refresh() cacheManager.checkCacheStatus() } + downloadCoordinator.downloads + .onEach { persistedDownloads -> + downloadStates.value = persistedDownloads.toDownloadStates() + } + .launchIn(viewModelScope) + historyRepository.historyEntries .combine(cacheManager.cacheState) { entries, _ -> entries } .combine(cacheRefreshEvents) { entries, _ -> entries } @@ -66,8 +72,10 @@ class HistoryViewModel( fun refreshCachedDownloadStates() { logcat(LogPriority.DEBUG, TAG) { "refreshCachedDownloadStates called" } - cacheManager.checkCacheStatus() - cacheRefreshEvents.update { it + 1 } + viewModelScope.launch(ioDispatcher) { + cacheManager.checkCacheStatus() + cacheRefreshEvents.update { it + 1 } + } } fun download(historyItem: HistoryItemUiModel) { @@ -77,12 +85,6 @@ class HistoryViewModel( "download requested uniqueKey=${entry.uniqueKey}, currentState=${currentState.javaClass.simpleName}, " + "historyItemState=${historyItem.downloadState.javaClass.simpleName}" } - if (canceledDownloads.value.keys.any { it.uniqueKey == entry.uniqueKey }) { - logcat(LogPriority.DEBUG, TAG) { - "download ignored because a canceled download is still finishing uniqueKey=${entry.uniqueKey}" - } - return - } when (currentState) { is DownloadState.InProgress -> { logcat(LogPriority.DEBUG, TAG) { @@ -107,107 +109,23 @@ class HistoryViewModel( else -> Unit } - val generation = nextDownloadGeneration++ - lateinit var downloadJob: Job - downloadJob = viewModelScope.launch(ioDispatcher, start = CoroutineStart.LAZY) { - updateDownloadStateIfActive(entry.uniqueKey, generation, DownloadState.InProgress(0f)) - val outputFile = getCachedFile(entry).selectedFile - outputFile.parentFile?.mkdirs() - logcat(LogPriority.DEBUG, TAG) { - "download started uniqueKey=${entry.uniqueKey}, url=${entry.downloadUrl}, " + - "outputPath=${outputFile.absolutePath}, parentExists=${outputFile.parentFile?.exists()}, " + - "preExisting=${outputFile.exists()}, preExistingLength=${outputFile.length()}" - } - - when ( - val result = downloadFileRepository.downloadFile( - downloadUrl = entry.downloadUrl, - outputFile = outputFile, - onProgress = { bytesDownloaded, totalBytes -> - val progress = if (totalBytes > 0) { - bytesDownloaded.toFloat() / totalBytes.toFloat() - } else { - 0f - } - updateDownloadStateIfActive( - entry.uniqueKey, - generation, - DownloadState.InProgress(progress), - ) - }, - ) - ) { - is NetworkResult.Success -> { - logcat(LogPriority.DEBUG, TAG) { - "download repository success uniqueKey=${entry.uniqueKey}, " + - "resultPath=${result.data.absolutePath}, resultExists=${result.data.exists()}, " + - "resultLength=${result.data.length()}, outputExists=${outputFile.exists()}, " + - "outputLength=${outputFile.length()}, parentExists=${outputFile.parentFile?.exists()}" - } - val downloadedFile = result.data.takeIf { it.exists() } ?: outputFile.takeIf { it.exists() } - if (downloadedFile == null) { - logcat(LogPriority.ERROR, TAG) { - "download success but file is missing uniqueKey=${entry.uniqueKey}, " + - "resultPath=${result.data.absolutePath}, outputPath=${outputFile.absolutePath}" - } - updateDownloadStateIfActive( - entry.uniqueKey, - generation, - DownloadState.DownloadFailed("Downloaded file is missing"), - ) - } else { - logcat(LogPriority.DEBUG, TAG) { - "download marked downloaded uniqueKey=${entry.uniqueKey}, " + - "path=${downloadedFile.absolutePath}, length=${downloadedFile.length()}" - } - updateDownloadStateIfActive( - entry.uniqueKey, - generation, - DownloadState.Downloaded(downloadedFile), - ) - } - cacheManager.checkCacheStatus() - cacheRefreshEvents.update { it + 1 } - } - is NetworkResult.Error -> { - logcat(LogPriority.ERROR, TAG) { - "download repository error uniqueKey=${entry.uniqueKey}, message=${result.message}" - } - updateDownloadStateIfActive( - entry.uniqueKey, - generation, - DownloadState.DownloadFailed(result.message), - ) - cacheManager.checkCacheStatus() - cacheRefreshEvents.update { it + 1 } - } - } + val outputFile = getCachedFile(entry).selectedFile + outputFile.parentFile?.mkdirs() + logcat(LogPriority.DEBUG, TAG) { + "download enqueued uniqueKey=${entry.uniqueKey}, url=${entry.downloadUrl}, " + + "outputPath=${outputFile.absolutePath}, parentExists=${outputFile.parentFile?.exists()}, " + + "preExisting=${outputFile.exists()}, preExistingLength=${outputFile.length()}" } - val activeDownload = ActiveDownload( - job = downloadJob, - generation = generation, - entry = entry, + downloadCoordinator.enqueue( + ApkDownloadRequest( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputFile = outputFile, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + ), ) - val downloadIdentity = DownloadIdentity(entry.uniqueKey, generation) - activeDownloads.update { it + (entry.uniqueKey to activeDownload) } - downloadJob.invokeOnCompletion { - activeDownloads.update { downloads -> - if (downloads[entry.uniqueKey]?.generation == generation) { - downloads - entry.uniqueKey - } else { - downloads - } - } - canceledDownloads.value[downloadIdentity]?.let { canceledEntry -> - if (activeDownloads.value[canceledEntry.uniqueKey] == null) { - deleteDownloadFiles(canceledEntry) - cacheManager.checkCacheStatus() - cacheRefreshEvents.update { it + 1 } - } - canceledDownloads.update { it - downloadIdentity } - } - } - downloadJob.start() } fun install(historyItem: HistoryItemUiModel, file: File) { @@ -219,27 +137,28 @@ class HistoryViewModel( } catch (_: Exception) { // History is best-effort; never block installation. } - intentManager.installApk(file) + installCoordinator.install( + historyItem.entry.uniqueKey, + file, + TryBuildProvenance( + project = historyItem.entry.project, + revision = historyItem.entry.revision, + commitMessage = historyItem.entry.commitMessage, + ), + ) } } + fun openInstalledApp(packageName: String) = installCoordinator.openInstalledApp(packageName) + fun delete(historyItem: HistoryItemUiModel) { val uniqueKey = historyItem.entry.uniqueKey viewModelScope.launch(ioDispatcher) { try { - activeDownloads.value[uniqueKey]?.let { activeDownload -> - val downloadIdentity = DownloadIdentity(uniqueKey, activeDownload.generation) - canceledDownloads.update { it + (downloadIdentity to activeDownload.entry) } - activeDownloads.update { downloads -> - if (downloads[uniqueKey]?.generation == activeDownload.generation) { - downloads - uniqueKey - } else { - downloads - } - } - activeDownload.job.cancel() - deleteDownloadFiles(activeDownload.entry) + if (downloadStates.value[uniqueKey] is DownloadState.InProgress) { + downloadCoordinator.cancel(uniqueKey) } + deleteDownloadFiles(historyItem.entry) historyRepository.delete(uniqueKey) downloadStates.update { it - uniqueKey } cacheManager.checkCacheStatus() @@ -256,20 +175,28 @@ class HistoryViewModel( downloadStates.update { it + (uniqueKey to downloadState) } } - private fun updateDownloadStateIfActive( - uniqueKey: String, - generation: Long, - downloadState: DownloadState, - ) { - if (activeDownloads.value[uniqueKey]?.generation == generation) { - updateDownloadState(uniqueKey, downloadState) - } else { - logcat(LogPriority.DEBUG, TAG) { - "ignored stale download state uniqueKey=$uniqueKey, generation=$generation, " + - "state=${downloadState.javaClass.simpleName}" + private fun Map.toDownloadStates(): Map = + mapValues { (_, persistedState) -> persistedState.toDownloadState() } + + private fun PersistedDownloadState.toDownloadState(): DownloadState = + when (status) { + DownloadStatus.QUEUED, + DownloadStatus.RUNNING, + -> DownloadState.InProgress( + progress = progress ?: 0f, + isIndeterminate = totalBytes <= 0L, + ) + DownloadStatus.SUCCEEDED -> { + val file = File(outputPath) + if (file.exists()) { + DownloadState.Downloaded(file) + } else { + DownloadState.NotDownloaded + } } + DownloadStatus.FAILED -> DownloadState.DownloadFailed(errorMessage) + DownloadStatus.CANCELED -> DownloadState.NotDownloaded } - } private fun List.toUiModels( states: Map, @@ -277,9 +204,7 @@ class HistoryViewModel( map { entry -> val cacheResolution = getCachedFile(entry) val rememberedState = states[entry.uniqueKey] - val isCanceledDownloadFinishing = canceledDownloads.value.keys.any { it.uniqueKey == entry.uniqueKey } val downloadState = when { - isCanceledDownloadFinishing -> DownloadState.InProgress(0f, isIndeterminate = true) rememberedState is DownloadState.InProgress -> rememberedState rememberedState is DownloadState.DownloadFailed -> rememberedState @@ -328,17 +253,6 @@ class HistoryViewModel( val selectedFile: File, ) - private data class ActiveDownload( - val job: Job, - val generation: Long, - val entry: TreeherderInstallHistoryEntry, - ) - - private data class DownloadIdentity( - val uniqueKey: String, - val generation: Long, - ) - private fun deleteDownloadFiles(entry: TreeherderInstallHistoryEntry) { val cacheResolution = getCachedFile(entry) setOf( diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt new file mode 100644 index 0000000..c0315eb --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -0,0 +1,392 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.CalendarToday +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.DatePicker +import androidx.compose.material3.DatePickerDialog +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.SelectableDates +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.material3.rememberDatePickerState +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.atStartOfDayIn +import kotlinx.datetime.toLocalDateTime +import org.mozilla.tryfox.R +import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.install.InstallState +import org.mozilla.tryfox.model.AppState +import org.mozilla.tryfox.ui.composables.AppIcon +import org.mozilla.tryfox.ui.composables.CurrentInstallState +import org.mozilla.tryfox.ui.composables.DownloadButton +import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment +import org.mozilla.tryfox.ui.models.ApkUiModel +import org.mozilla.tryfox.ui.models.ApksResult +import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.ui.models.NightlyBuildOption +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_RELEASE +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_DEBUG +import org.mozilla.tryfox.util.FOCUS_RELEASE +import org.mozilla.tryfox.util.parseDateToMillis +import org.mozilla.tryfox.util.withoutTrailingReviewerDirective + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +internal fun HomeAppCard( + card: HomeAppCardUiModel, + installStates: Map, + onFlavorSelected: (String) -> Unit, + onDownloadClick: (ApkUiModel) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, + onOpenInstalledApp: (String) -> Unit, + onOpenTryBuild: (String, String) -> Unit, + onDateSelected: (String, LocalDate) -> Unit, + dateValidator: (LocalDate) -> Boolean, + onReleaseVersionSelected: (String, String) -> Unit, + onBuildSelected: (String, String) -> Unit, + onDismissBuildPicker: (String) -> Unit, + modifier: Modifier = Modifier, +) { + val app = card.selectedApp + val appState = app.toAppState() + val title = cardTitle(card.family) + val subtitle = cardSubtitle(card.family, app.name, card.showFlavorSelector) + val tag = card.stableKey.lowercase() + val flavorAppNames = card.family.appNames.filter { it in card.appsByName } + val isNightly = app.name == FENIX || app.name == FOCUS + val isDebug = app.name == FENIX_DEBUG || app.name == FOCUS_DEBUG + val isVersionSelectable = app.name in setOf(FENIX_RELEASE, FENIX_BETA, FOCUS_RELEASE, FOCUS_BETA) + val selectedApk = (app.apks as? ApksResult.Success)?.apks + ?.let { apks -> apks.firstOrNull { it.abi.isSupported } ?: apks.firstOrNull() } + + Card( + modifier = modifier + .fillMaxWidth() + .animateContentSize() + .testTag("home_app_card_$tag"), + shape = MaterialTheme.shapes.extraLarge, + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + border = BorderStroke(1.dp, MaterialTheme.colorScheme.outlineVariant), + ) { + Column(modifier = Modifier.padding(20.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon( + appName = app.name, + modifier = Modifier + .size(60.dp) + .then( + if (appState?.isInstalled == true) { + Modifier + .clickable { onOpenInstalledApp(appState.packageName) } + .testTag("home_open_icon_$tag") + } else { + Modifier + }, + ), + ) + Column { + Text(title, style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + Text(subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant) + } + } + + CurrentInstallState( + appState = appState, + appDisplayName = title, + modifier = Modifier.testTag("home_install_status_$tag").padding(top = 8.dp), + ) + + if (card.showFlavorSelector && flavorAppNames.size > 1) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + flavorAppNames.forEach { appName -> + val isInstalled = card.appsByName[appName]?.installedVersion != null + val installedStateDescription = stringResource(R.string.installed_chip_label) + FilterChip( + selected = appName == app.name, + onClick = { onFlavorSelected(appName) }, + label = { Text(flavorLabel(appName)) }, + border = BorderStroke( + width = if (isInstalled) 2.dp else 1.dp, + color = if (isInstalled) { + MaterialTheme.colorScheme.primary + } else { + MaterialTheme.colorScheme.outlineVariant + }, + ), + modifier = Modifier + .testTag("home_flavor_${tag}_$appName") + .semantics { + if (isInstalled) stateDescription = installedStateDescription + }, + ) + } + } + } + + if (isDebug && app.installedTryBuild != null) { + Spacer(modifier = Modifier.height(12.dp)) + } else if (!isDebug) { + Row( + modifier = Modifier.fillMaxWidth().padding(top = 12.dp), + verticalAlignment = Alignment.Top, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column(modifier = Modifier.weight(1f)) { + when { + app.apks is ApksResult.Loading -> CircularProgressIndicator(modifier = Modifier.size(28.dp)) + app.apks is ApksResult.Error -> Text(app.apks.message, color = MaterialTheme.colorScheme.error, style = MaterialTheme.typography.bodySmall) + selectedApk != null && isNightly -> NightlyDetails( + appName = app.name, + version = selectedApk.version, + date = selectedApk.date, + buildDate = selectedApk.buildDate, + selectedDate = app.userPickedDate, + dateValidator = dateValidator, + onDateSelected = onDateSelected, + ) + selectedApk != null && isVersionSelectable -> ReleaseVersionDetails( + appName = app.name, + selectedVersion = app.selectedReleaseVersion ?: selectedApk.version, + versions = app.availableReleaseVersions, + onSelected = onReleaseVersionSelected, + ) + selectedApk != null -> Text(selectedApk.version, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + else -> Text(stringResource(R.string.home_no_apks_available), style = MaterialTheme.typography.bodyMedium) + } + } + if (selectedApk != null || app.apks is ApksResult.Loading) { + Spacer(Modifier.width(12.dp)) + DownloadButton( + downloadState = selectedApk?.downloadState ?: DownloadState.NotDownloaded, + onDownloadClick = { selectedApk?.let(onDownloadClick) }, + onInstallClick = { selectedApk?.let(onInstallClick) }, + installState = selectedApk?.let { installStates[it.uniqueKey] } ?: InstallState.Idle, + installDisabled = selectedApk == null, + onOpenClick = onOpenInstalledApp, + debugLabel = "home-card:${selectedApk?.uniqueKey ?: app.name}", + modifier = Modifier + .testTag("home_primary_action_$tag") + .semantics { contentDescription = "Download $title" }, + ) + } + } + } + + app.installedTryBuild?.let { build -> + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 8.dp) + .clip(MaterialTheme.shapes.medium) + .background(MaterialTheme.colorScheme.surfaceContainer) + .clickable { onOpenTryBuild(build.project, build.revision) } + .semantics { contentDescription = "Open Try build revision ${build.revision}" } + .testTag("home_try_build_revision") + .padding(12.dp), + ) { + Text( + text = stringResource( + R.string.home_try_build_revision_label, + build.revision.take(SHORT_REVISION_LENGTH), + ), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelMedium, + ) + val commitTitle = build.commitMessage.withoutTrailingReviewerDirective() + .lineSequence().firstOrNull { it.isNotBlank() }.orEmpty() + Text( + text = rememberLinkedPushComment(commitTitle), + modifier = Modifier.padding(top = 4.dp), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + } + + if (app.pendingBuildOptions.isNotEmpty()) { + NightlyBuildPickerDialog( + options = app.pendingBuildOptions, + onSelect = { onBuildSelected(app.name, it) }, + onDismiss = { onDismissBuildPicker(app.name) }, + ) + } +} + +private const val SHORT_REVISION_LENGTH = 12 + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun NightlyDetails( + appName: String, + version: String, + date: String, + buildDate: LocalDate?, + selectedDate: LocalDate?, + dateValidator: (LocalDate) -> Boolean, + onDateSelected: (String, LocalDate) -> Unit, +) { + var showPicker by remember { mutableStateOf(false) } + Text(version, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + AssistChip( + onClick = { showPicker = true }, + label = { Text(date.ifBlank { selectedDate?.toString().orEmpty() }) }, + leadingIcon = { Icon(Icons.Default.CalendarToday, null, Modifier.size(18.dp)) }, + modifier = Modifier.testTag("home_nightly_date_$appName"), + ) + if (showPicker) { + val initialDate = selectedDate ?: buildDate + ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault()).date + val state = rememberDatePickerState( + initialSelectedDateMillis = initialDate.toDatePickerSelectionMillis(), + selectableDates = object : SelectableDates { + override fun isSelectableDate(utcTimeMillis: Long): Boolean = dateValidator(Instant.fromEpochMilliseconds(utcTimeMillis).toLocalDateTime(TimeZone.UTC).date) + }, + ) + DatePickerDialog( + onDismissRequest = { showPicker = false }, + confirmButton = { TextButton(onClick = { state.selectedDateMillis?.let { onDateSelected(appName, datePickerSelectionDate(it)) }; showPicker = false }) { Text("OK") } }, + dismissButton = { TextButton(onClick = { showPicker = false }) { Text("Cancel") } }, + ) { DatePicker(state = state) } + } +} + +/** Material DatePicker represents a selected calendar day as midnight UTC. */ +internal fun LocalDate.toDatePickerSelectionMillis(): Long = + atStartOfDayIn(TimeZone.UTC).toEpochMilliseconds() + +internal fun datePickerSelectionDate(selectionMillis: Long): LocalDate = + Instant.fromEpochMilliseconds(selectionMillis).toLocalDateTime(TimeZone.UTC).date + +@Composable +private fun ReleaseVersionDetails(appName: String, selectedVersion: String, versions: List, onSelected: (String, String) -> Unit) { + var expanded by remember { mutableStateOf(false) } + Box { + Row( + modifier = Modifier.clickable(enabled = versions.isNotEmpty()) { expanded = true } + .testTag("home_release_version_$appName").padding(vertical = 4.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text(selectedVersion, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Icon(Icons.Default.ArrowDropDown, contentDescription = "Select version") + } + DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { + versions.forEach { version -> DropdownMenuItem(text = { Text(version) }, onClick = { expanded = false; onSelected(appName, version) }) } + } + } +} + +private fun AppUiModel.toAppState(): AppState? = installedVersion?.let { version -> + AppState( + name = name, + packageName = packageName, + version = version, + installDateMillis = installedDate?.let(::parseDateToMillis), + installingPackageName = installingPackageName, + versionCode = installedVersionCode, + splitNames = splitNames, + ) +} + +@Composable +private fun NightlyBuildPickerDialog( + options: List, + onSelect: (String) -> Unit, + onDismiss: () -> Unit, +) { + AlertDialog( + onDismissRequest = onDismiss, + title = { Text(stringResource(R.string.home_select_build)) }, + text = { + Column { + options.forEach { option -> + Text( + option.label, + modifier = Modifier.fillMaxWidth().clickable { onSelect(option.id) }.padding(vertical = 12.dp), + ) + } + } + }, + confirmButton = {}, + dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }, + ) +} + +@Composable private fun cardTitle(family: HomeAppFamily): String = when (family) { + HomeAppFamily.Fenix -> stringResource(R.string.home_app_title_fenix) + HomeAppFamily.Focus -> stringResource(R.string.home_app_title_focus) + HomeAppFamily.ReferenceBrowser -> stringResource(R.string.home_app_title_reference_browser) +} + +@Composable private fun cardSubtitle( + family: HomeAppFamily, + appName: String, + showFlavorSelector: Boolean, +): String = when { + !showFlavorSelector && family != HomeAppFamily.ReferenceBrowser -> flavorLabel(appName) + else -> cardFamilySubtitle(family) +} + +@Composable private fun cardFamilySubtitle(family: HomeAppFamily): String = when (family) { + HomeAppFamily.Fenix -> stringResource(R.string.home_app_subtitle_fenix) + HomeAppFamily.Focus -> stringResource(R.string.home_app_subtitle_focus) + HomeAppFamily.ReferenceBrowser -> stringResource(R.string.home_app_subtitle_reference_browser) +} + +private fun flavorLabel(appName: String): String = when (appName) { + FENIX, FOCUS -> "Nightly" + FENIX_BETA, FOCUS_BETA -> "Beta" + FENIX_DEBUG, FOCUS_DEBUG -> "Debug" + else -> "Release" +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt new file mode 100644 index 0000000..145b7f0 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCardModel.kt @@ -0,0 +1,70 @@ +package org.mozilla.tryfox.ui.screens + +import org.mozilla.tryfox.model.HomeScreenLayout +import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_RELEASE +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_DEBUG +import org.mozilla.tryfox.util.FOCUS_RELEASE +import org.mozilla.tryfox.util.REFERENCE_BROWSER + +/** The three product cards shown on Home. */ +enum class HomeAppFamily(val appNames: List, val defaultAppName: String) { + Fenix(listOf(FENIX_RELEASE, FENIX_BETA, FENIX, FENIX_DEBUG), FENIX), + Focus(listOf(FOCUS_RELEASE, FOCUS_BETA, FOCUS, FOCUS_DEBUG), FOCUS_BETA), + ReferenceBrowser(listOf(REFERENCE_BROWSER), REFERENCE_BROWSER), +} + +data class HomeAppCardUiModel( + val family: HomeAppFamily, + val selectedAppName: String, + val appsByName: Map, + val showFlavorSelector: Boolean = true, +) { + val selectedApp: AppUiModel get() = appsByName.getValue(selectedAppName) + val stableKey: String get() = if (showFlavorSelector) family.name else selectedAppName +} + +internal fun homeAppCards( + apps: Map, + selectedAppNames: Map, + layout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, +): List = when (layout) { + HomeScreenLayout.OneCardPerApp -> groupedHomeAppCards(apps, selectedAppNames) + HomeScreenLayout.OneCardPerFlavor -> flavorHomeAppCards(apps) +} + +private fun groupedHomeAppCards( + apps: Map, + selectedAppNames: Map, +): List = HomeAppFamily.entries.mapNotNull { family -> + val familyApps = family.appNames.mapNotNull { name -> + apps[name]?.takeUnless { name.isDebugFlavor && it.installedVersion == null }?.let { name to it } + }.toMap() + if (familyApps.isEmpty()) return@mapNotNull null + val selected = selectedAppNames[family].takeIf { it in familyApps } ?: family.defaultAppName + HomeAppCardUiModel(family, selected, familyApps) +} + +private fun flavorHomeAppCards(apps: Map): List = + HomeAppFamily.entries.flatMap { family -> + family.appNames.mapNotNull { appName -> + apps[appName] + ?.takeUnless { appName.isDebugFlavor && it.installedVersion == null } + ?.let { app -> + HomeAppCardUiModel( + family = family, + selectedAppName = appName, + appsByName = mapOf(appName to app), + showFlavorSelector = false, + ) + } + } + } + +private val String.isDebugFlavor: Boolean + get() = this == FENIX_DEBUG || this == FOCUS_DEBUG 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 17779ab..18ad44f 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 @@ -1,6 +1,5 @@ package org.mozilla.tryfox.ui.screens -import androidx.compose.animation.core.animateDpAsState import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -12,10 +11,10 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.ExperimentalMaterialApi import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.AccountCircle import androidx.compose.material.icons.filled.CameraAlt import androidx.compose.material.icons.filled.History import androidx.compose.material.icons.filled.Search +import androidx.compose.material.icons.filled.Settings import androidx.compose.material.pullrefresh.PullRefreshIndicator import androidx.compose.material.pullrefresh.pullRefresh import androidx.compose.material.pullrefresh.rememberPullRefreshState @@ -24,14 +23,10 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.PlainTooltip import androidx.compose.material3.Scaffold import androidx.compose.material3.Text -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults 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 @@ -47,40 +42,32 @@ import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel -import kotlinx.datetime.LocalDate import org.mozilla.tryfox.R -import org.mozilla.tryfox.model.AppState -import org.mozilla.tryfox.model.CacheManagementState -import org.mozilla.tryfox.ui.composables.ArchiveGroupCard -import org.mozilla.tryfox.ui.composables.BinButton -import org.mozilla.tryfox.ui.models.ApkUiModel -import org.mozilla.tryfox.ui.models.ApksResult -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 onNavigateToSearch Callback to navigate to the unified build search screen. * @param onNavigateToHistory Callback to navigate to the History screen. + * @param onNavigateToSettings Callback to navigate to the Settings screen. * @param homeViewModel The ViewModel for the Home screen. */ @OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) @Composable fun HomeScreen( modifier: Modifier = Modifier, - onNavigateToTreeherder: () -> Unit, - onNavigateToProfile: () -> Unit, + onNavigateToSearch: () -> Unit, onNavigateToQrScanner: () -> Unit, onNavigateToReceiveFromDesktop: () -> Unit, onNavigateToHistory: () -> Unit, + onNavigateToSettings: () -> Unit, + onNavigateToTryBuild: (String, String) -> Unit = { _, _ -> }, homeViewModel: HomeViewModel = viewModel(), ) { val screenState by homeViewModel.homeScreenState.collectAsState() val isRefreshing by homeViewModel.isRefreshing.collectAsState() + val installStates by homeViewModel.installStates.collectAsState() val pullRefreshState = rememberPullRefreshState(isRefreshing, { homeViewModel.refreshData() }) LaunchedEffect(Unit) { @@ -90,13 +77,6 @@ fun HomeScreen( Scaffold( modifier = modifier.fillMaxSize(), topBar = { - val loadedState = screenState as? HomeScreenState.Loaded - val currentCacheState = - loadedState?.cacheManagementState ?: CacheManagementState.IdleEmpty - val isDownloading = loadedState?.isDownloadingAnyFile ?: false - val binButtonEnabled = - !isDownloading && currentCacheState == CacheManagementState.IdleNonEmpty - TopAppBar( title = { Text(stringResource(id = R.string.app_name)) }, colors = TopAppBarDefaults.topAppBarColors( @@ -123,13 +103,7 @@ fun HomeScreen( contentDescription = null, ) } - IconButton(onClick = onNavigateToProfile) { - Icon( - imageVector = Icons.Filled.AccountCircle, - contentDescription = stringResource(id = R.string.home_profile_button_description), - ) - } - IconButton(onClick = onNavigateToTreeherder) { + IconButton(onClick = onNavigateToSearch) { Icon( imageVector = Icons.Filled.Search, contentDescription = stringResource( @@ -137,20 +111,10 @@ fun HomeScreen( ), ) } - val tooltipState = rememberTooltipState() - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text(stringResource(id = R.string.bin_button_tooltip_clear_downloaded_apks)) - } - }, - state = tooltipState, - ) { - BinButton( - cacheState = currentCacheState, - onConfirm = { homeViewModel.clearAppCache() }, - enabled = binButtonEnabled, + IconButton(onClick = onNavigateToSettings) { + Icon( + imageVector = Icons.Filled.Settings, + contentDescription = stringResource(id = R.string.home_settings_button_description), ) } }, @@ -182,37 +146,39 @@ fun HomeScreen( is HomeScreenState.Loaded -> { val tryFoxApp = currentScreenState.tryfoxApp - val otherApps = currentScreenState.apps.values.toList() - - val targetSpacerHeight = if (tryFoxApp != null) tryFoxCardHeight + 4.dp else 0.dp - val animatedSpacerHeight by animateDpAsState(targetValue = targetSpacerHeight, label = "tryFoxSpacerHeight") + val cards = homeAppCards( + apps = currentScreenState.apps, + selectedAppNames = currentScreenState.selectedAppNames, + layout = currentScreenState.homeScreenLayout, + ) LazyColumn( modifier = Modifier .fillMaxSize() .padding(horizontal = 16.dp), horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Top, + verticalArrangement = Arrangement.spacedBy(16.dp), ) { - item { - Spacer(modifier = Modifier.height(animatedSpacerHeight)) - } + item { Spacer(modifier = Modifier.height(if (tryFoxApp != null) tryFoxCardHeight + 4.dp else 0.dp)) } - items(otherApps) { app -> - AppComponent( - app = app, + items(cards, key = { it.stableKey }) { card -> + HomeAppCard( + card = card, + onFlavorSelected = { appName -> + homeViewModel.selectHomeAppFlavor(card.family, appName) + }, onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, - onInstallClick = { homeViewModel.installApk(it) }, - onOpenAppClick = { homeViewModel.openApp(it) }, - onUninstallClick = { homeViewModel.uninstallApp(it) }, + onInstallClick = homeViewModel::installHomeApk, + installStates = installStates, + onOpenInstalledApp = homeViewModel::openInstalledApp, + onOpenTryBuild = onNavigateToTryBuild, onDateSelected = { appName, date -> homeViewModel.onDateSelected( appName, date, ) }, - dateValidator = homeViewModel.getDateValidator(app.name), - onClearDate = { appName -> homeViewModel.onClearDate(appName) }, + dateValidator = homeViewModel.getDateValidator(card.selectedApp.name), onReleaseVersionSelected = { appName, version -> homeViewModel.onReleaseVersionSelected(appName, version) }, @@ -231,7 +197,9 @@ fun HomeScreen( modifier = Modifier.align(Alignment.TopCenter), tryFoxApp = tryFoxApp, onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, - onInstallClick = { homeViewModel.installApk(it) }, + onInstallClick = homeViewModel::installHomeApk, + installStates = installStates, + onOpenInstalledApp = homeViewModel::openInstalledApp, onDismiss = { homeViewModel.dismissTryFoxCard() }, onTryFoxCardHeightChange = { tryFoxCardHeight = it }, ) @@ -272,77 +240,3 @@ private fun TopBarActionIcon( icon() } } - -/** - * 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, - onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (File) -> Unit, - onOpenAppClick: (String) -> Unit, - onUninstallClick: (String) -> Unit, - onDateSelected: (String, LocalDate) -> Unit, - dateValidator: (LocalDate) -> Boolean, - onClearDate: (String) -> Unit, - onReleaseVersionSelected: (String, String) -> Unit, - onBuildSelected: (String, String) -> Unit, - onDismissBuildPicker: (String) -> Unit, -) { - val apksResult = app.apks - - val appState = if (app.installedVersion != null) { - AppState( - name = app.name, - packageName = app.packageName, - version = app.installedVersion, - versionCode = app.installedVersionCode, - installDateMillis = app.installedDate?.let { parseDateToMillis(it) }, - installingPackageName = app.installingPackageName, - splitNames = app.splitNames, - ) - } else { - null - } - - ArchiveGroupCard( - modifier = Modifier.padding(vertical = 8.dp), - apks = (apksResult as? ApksResult.Success)?.apks ?: emptyList(), - appState = appState, - onDownloadClick = onDownloadClick, - onInstallClick = onInstallClick, - onOpenAppClick = { - appState?.packageName?.let { - onOpenAppClick(it) - } - }, - onUninstallClick = { - appState?.packageName?.let { - onUninstallClick(it) - } - }, - onDateSelected = { date -> onDateSelected(app.name, date) }, - userPickedDate = app.userPickedDate, - selectedReleaseVersion = app.selectedReleaseVersion, - availableReleaseVersions = app.availableReleaseVersions, - onReleaseVersionSelected = { version -> onReleaseVersionSelected(app.name, version) }, - appName = app.name, - errorMessage = (apksResult as? ApksResult.Error)?.message, - isLoading = apksResult is ApksResult.Loading, - dateValidator = dateValidator, - onClearDate = { onClearDate(app.name) }, - pendingBuildOptions = app.pendingBuildOptions, - onBuildSelected = { buildId -> onBuildSelected(app.name, buildId) }, - onDismissBuildPicker = { onDismissBuildPicker(app.name) }, - ) - Spacer(modifier = Modifier.height(16.dp)) -} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt index 4ffc996..58f5668 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox.ui.screens import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.ui.models.AppUiModel /** @@ -20,5 +21,7 @@ sealed class HomeScreenState { val tryfoxApp: AppUiModel?, val cacheManagementState: CacheManagementState, val isDownloadingAnyFile: Boolean, + val selectedAppNames: Map = HomeAppFamily.entries.associateWith { it.defaultAppName }, + val homeScreenLayout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, ) : HomeScreenState() } 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 0273ebd..b343523 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 @@ -11,21 +11,37 @@ import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.todayIn import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.data.InstalledTryBuild 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.data.repositories.CachedHomeApk +import org.mozilla.tryfox.data.repositories.CachedHomeApp import org.mozilla.tryfox.data.repositories.DateAwareReleaseRepository -import org.mozilla.tryfox.data.repositories.DownloadFileRepository +import org.mozilla.tryfox.data.repositories.EmptyHomeDataCacheRepository +import org.mozilla.tryfox.data.repositories.EmptyInstalledTryBuildRepository +import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository +import org.mozilla.tryfox.data.repositories.HomeDataSnapshot +import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository +import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.data.repositories.VersionAwareReleaseRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.AppState -import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel @@ -35,8 +51,11 @@ import org.mozilla.tryfox.ui.models.NightlyBuildOption import org.mozilla.tryfox.ui.models.newVersionAvailable import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_DEBUG import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TRYFOX @@ -46,7 +65,7 @@ import java.io.File * ViewModel for the Home screen, responsible for fetching and displaying nightly builds of different Mozilla apps. * * @param releaseRepositories A list of release repositories. - * @param downloadFileRepository Repository for downloading files. + * @param downloadCoordinator Coordinator for WorkManager-backed APK downloads. * @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. @@ -54,11 +73,15 @@ import java.io.File */ class HomeViewModel( private val releaseRepositories: List, - private val downloadFileRepository: DownloadFileRepository, + private val downloadCoordinator: ApkDownloadCoordinator, private val mozillaPackageManager: MozillaPackageManager, private val cacheManager: CacheManager, private val intentManager: IntentManager, + private val installCoordinator: ApkInstallCoordinator, private val ioDispatcher: CoroutineDispatcher, + private val userDataRepository: UserDataRepository? = null, + private val homeDataCacheRepository: HomeDataCacheRepository = EmptyHomeDataCacheRepository, + private val installedTryBuildRepository: InstalledTryBuildRepository = EmptyInstalledTryBuildRepository, private val supportedAbis: List = Build.SUPPORTED_ABIS.toList(), ) : ViewModel() { @@ -67,50 +90,53 @@ class HomeViewModel( private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + val installStates: StateFlow> = installCoordinator.states + private val downloadStates = MutableStateFlow>(emptyMap()) + private var currentAppsByName: Map = emptyMap() + private var cachedAppsByName: Map = emptyMap() + private var installedTryBuild: InstalledTryBuild? = null + private var initialLoadStarted = false + private var tryFoxCardDismissed = false + private var homeScreenLayout = HomeScreenLayout.OneCardPerApp + + @Volatile + private var selectedHomeAppNames = HomeAppFamily.entries.associateWith { it.defaultAppName } + private val appMutationVersions = mutableMapOf() + private val appsLock = Any() + private val refreshMutex = Mutex() + private val refreshStateMutex = Mutex() + private var activeRefreshes = 0 init { + downloadCoordinator.downloads + .onEach { persistedDownloads -> + downloadStates.value = persistedDownloads + syncLoadedStateDownloadStates() + } + .launchIn(viewModelScope) + cacheManager.cacheState .onEach { newCacheState -> _homeScreenState.update { currentState -> if (currentState !is HomeScreenState.Loaded) return@update currentState + currentState.copy(cacheManagementState = newCacheState) + } + syncLoadedStateDownloadStates() + } + .launchIn(viewModelScope) - 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) - } - app.copy(apks = ApksResult.Success(updatedApks)) - } - } else { - currentState.apps - } - - val updatedTryFoxApp = if (newCacheState is CacheManagementState.IdleEmpty) { - currentState.tryfoxApp?.let { app -> - val apksResult = app.apks as? ApksResult.Success ?: return@let app - val updatedApks = apksResult.apks.map { - it.copy(downloadState = DownloadState.NotDownloaded) - } - app.copy(apks = ApksResult.Success(updatedApks)) - } + userDataRepository?.homeScreenLayoutFlow + ?.onEach { layout -> + homeScreenLayout = layout + _homeScreenState.update { currentState -> + if (currentState is HomeScreenState.Loaded) { + currentState.copy(homeScreenLayout = layout) } else { - currentState.tryfoxApp + currentState } - - currentState.copy( - apps = updatedApps, - tryfoxApp = updatedTryFoxApp, - cacheManagementState = newCacheState, - isDownloadingAnyFile = if (newCacheState is CacheManagementState.IdleEmpty) { - false - } else { - currentState.isDownloadingAnyFile - }, - ) } } - .launchIn(viewModelScope) + ?.launchIn(viewModelScope) mozillaPackageManager.appStates .onEach { appState -> @@ -125,6 +151,8 @@ class HomeViewModel( installedDate = appState.formattedInstallDate, installingPackageName = appState.installingPackageName, splitNames = appState.splitNames, + installedTryBuild = app.name.takeIf { it == FENIX_DEBUG } + ?.let { matchingInstalledTryBuild(appState) }, ) } else { app @@ -136,82 +164,233 @@ class HomeViewModel( } } }.launchIn(viewModelScope) + + installedTryBuildRepository.installedTryBuild + .onEach { build -> + installedTryBuild = build + _homeScreenState.update { currentState -> + if (currentState !is HomeScreenState.Loaded) return@update currentState + currentState.copy( + apps = currentState.apps.mapValues { (_, app) -> + if (app.name == FENIX_DEBUG) { + app.copy( + installedTryBuild = matchingInstalledTryBuild( + app.packageName, + app.installedVersion, + app.installedVersionCode, + ), + ) + } else { + app + } + }, + ) + } + } + .launchIn(viewModelScope) } fun initialLoad() { - viewModelScope.launch(ioDispatcher) { - _homeScreenState.value = HomeScreenState.InitialLoading - _isRefreshing.value = true - cacheManager.checkCacheStatus() // Initial check - fetchData() - _isRefreshing.value = false - } + if (initialLoadStarted) return + initialLoadStarted = true + launchRefresh(hydrateCache = true) } fun refreshData() { + launchRefresh(hydrateCache = false) + } + + fun selectHomeAppFlavor(family: HomeAppFamily, appName: String) { + if (appName !in family.appNames) return + selectedHomeAppNames = selectedHomeAppNames + (family to appName) + _homeScreenState.update { state -> + if (state !is HomeScreenState.Loaded) state + else state.copy(selectedAppNames = selectedHomeAppNames) + } + } + + private fun launchRefresh(hydrateCache: Boolean) { viewModelScope.launch(ioDispatcher) { - _isRefreshing.value = true - fetchData() - _isRefreshing.value = false + markRefreshStarted() + try { + refreshMutex.withLock { + if (hydrateCache) { + cacheManager.checkCacheStatus() + hydrateCachedData() + } + fetchData() + } + } finally { + markRefreshFinished() + } + } + } + + private suspend fun markRefreshStarted() = refreshStateMutex.withLock { + activeRefreshes += 1 + _isRefreshing.value = true + } + + private suspend fun markRefreshFinished() = refreshStateMutex.withLock { + activeRefreshes -= 1 + _isRefreshing.value = activeRefreshes > 0 + } + + private suspend fun hydrateCachedData() { + val snapshot = homeDataCacheRepository.read() ?: return + val appInfoMap = appInfoMap() + val cachedApps = snapshot.apps.associate { cachedApp -> + cachedApp.appName to cachedApp.toAppUiModel(appInfoMap[cachedApp.appName]) + } + if (cachedApps.isEmpty()) return + + synchronized(appsLock) { + cachedAppsByName = cachedApps + currentAppsByName = initialApps(appInfoMap) + cachedApps } + publishCurrentApps() } private suspend fun fetchData() { - val appInfoMap = mapOf( + val appInfoMap = appInfoMap() + val mutationVersionsAtStart = synchronized(appsLock) { appMutationVersions.toMap() } + if (_homeScreenState.value !is HomeScreenState.Loaded) { + synchronized(appsLock) { + currentAppsByName = initialApps(appInfoMap) + } + publishCurrentApps() + } + + val fetchedApps = releaseRepositories.associate { repository -> + repository.appName to buildAppUiModel(repository, appInfoMap[repository.appName]) + } + applyFetchedApps(fetchedApps, mutationVersionsAtStart) + publishCurrentApps() + persistSuccessfulApps() + } + + private fun appInfoMap(): Map { + return mapOf( FENIX to mozillaPackageManager.fenix, FENIX_RELEASE to mozillaPackageManager.fenixRelease, FENIX_BETA to mozillaPackageManager.fenixBeta, + FENIX_DEBUG to mozillaPackageManager.fenixDebug, FOCUS to mozillaPackageManager.focus, FOCUS_RELEASE to mozillaPackageManager.focusRelease, + FOCUS_BETA to mozillaPackageManager.focusBeta, + FOCUS_DEBUG to mozillaPackageManager.fenixDebug, REFERENCE_BROWSER to mozillaPackageManager.referenceBrowser, TRYFOX to mozillaPackageManager.tryfox, ) + } - _homeScreenState.update { - val currentCacheState = cacheManager.cacheState.value - val initialApps = appInfoMap.mapValues { (appName, appState) -> - AppUiModel( - name = appName, - packageName = appState.packageName, - installedVersion = appState.version, - installedVersionCode = appState.versionCode, - installedDate = appState.formattedInstallDate, - installingPackageName = appState.installingPackageName, - splitNames = appState.splitNames, - apks = ApksResult.Loading, - ) - } - HomeScreenState.Loaded( - apps = initialApps.filterNot { (key, _) -> key == TRYFOX }, - tryfoxApp = initialApps[TRYFOX], - cacheManagementState = currentCacheState, - isDownloadingAnyFile = false, + private fun initialApps(appInfoMap: Map): Map = + appInfoMap.mapValues { (appName, appState) -> + AppUiModel( + name = appName, + packageName = appState.packageName, + installedVersion = appState.version, + installedVersionCode = appState.versionCode, + installedDate = appState.formattedInstallDate, + installingPackageName = appState.installingPackageName, + splitNames = appState.splitNames, + installedTryBuild = appName.takeIf { it == FENIX_DEBUG } + ?.let { matchingInstalledTryBuild(appState) }, + apks = ApksResult.Loading, ) } - val newApps = releaseRepositories.associate { repository -> - repository.appName to buildAppUiModel(repository, appInfoMap[repository.appName]) - } + private fun publishCurrentApps() { + val apps = synchronized(appsLock) { currentAppsByName } + val currentCacheState = cacheManager.cacheState.value + val tryFoxApp = apps[TRYFOX] + ?.takeIf { !tryFoxCardDismissed && it.newVersionAvailable } + _homeScreenState.value = HomeScreenState.Loaded( + apps = apps.filterNot { (key, _) -> key == TRYFOX }, + tryfoxApp = tryFoxApp, + cacheManagementState = currentCacheState, + isDownloadingAnyFile = false, + selectedAppNames = selectedHomeAppNames, + homeScreenLayout = homeScreenLayout, + ).applyDownloadStates(downloadStates.value) + } - val isDownloading = newApps.values.any { app -> - (app.apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true + private fun updateCurrentApp(appName: String, update: (AppUiModel) -> AppUiModel) { + synchronized(appsLock) { + val currentApp = currentAppsByName[appName] ?: return + currentAppsByName = currentAppsByName + (appName to update(currentApp)) + appMutationVersions[appName] = (appMutationVersions[appName] ?: 0) + 1 } + } - val tryFoxApp = newApps[TRYFOX]?.takeIf { it.newVersionAvailable } - - _homeScreenState.update { - if (it is HomeScreenState.Loaded) { - it.copy( - apps = newApps.filterNot { (key, _) -> key == TRYFOX }, - tryfoxApp = tryFoxApp, - isDownloadingAnyFile = isDownloading, - ) - } else { - it + private fun applyFetchedApps( + fetchedApps: Map, + mutationVersionsAtStart: Map, + ) { + synchronized(appsLock) { + val mergedApps = fetchedApps.mapValues { (appName, fetchedApp) -> + val cachedApp = currentAppsByName[appName] + val changedWhileRefreshing = appMutationVersions[appName] != mutationVersionsAtStart[appName] + if (changedWhileRefreshing && cachedApp != null) { + cachedApp + } else if (fetchedApp.apks is ApksResult.Error && cachedApp?.apks is ApksResult.Success) { + cachedApp + } else { + fetchedApp + } } + currentAppsByName = currentAppsByName + mergedApps + cachedAppsByName = cachedAppsByName + fetchedApps.filterValues { it.apks is ApksResult.Success } + } + } + + private suspend fun persistSuccessfulApps() { + val cachedApps = synchronized(appsLock) { cachedAppsByName } + val successfulApps = cachedApps.values.mapNotNull { app -> + val successfulApks = app.apks as? ApksResult.Success ?: return@mapNotNull null + CachedHomeApp( + appName = app.name, + apks = successfulApks.apks.map(::toCachedHomeApk), + selectedReleaseVersion = app.selectedReleaseVersion, + availableReleaseVersions = app.availableReleaseVersions, + ) + } + if (successfulApps.isNotEmpty()) { + homeDataCacheRepository.write( + HomeDataSnapshot(version = HomeDataSnapshot.CURRENT_VERSION, apps = successfulApps), + ) } } + private fun CachedHomeApp.toAppUiModel(appState: AppState?): AppUiModel = AppUiModel( + name = appName, + packageName = appState?.packageName.orEmpty(), + installedVersion = appState?.version, + installedVersionCode = appState?.versionCode, + installedDate = appState?.formattedInstallDate, + installingPackageName = appState?.installingPackageName, + splitNames = appState?.splitNames.orEmpty(), + installedTryBuild = appState?.takeIf { appName == FENIX_DEBUG }?.let(::matchingInstalledTryBuild), + apks = ApksResult.Success(apks.map { it.toUiModel() }), + selectedReleaseVersion = selectedReleaseVersion, + availableReleaseVersions = availableReleaseVersions, + ) + + private fun CachedHomeApk.toUiModel(): ApkUiModel { + val parsed = MozillaArchiveApk(originalString, rawDateString, appName, version, abiName, fullUrl, fileName) + return convertParsedApksToUiModels(listOf(parsed)).single() + } + + private fun toCachedHomeApk(apk: ApkUiModel): CachedHomeApk = CachedHomeApk( + originalString = apk.originalString, + rawDateString = apk.uniqueKey.split('/').let { parts -> parts.getOrNull(1)?.takeIf { parts.size > 2 } }, + appName = apk.appName, + version = apk.version, + abiName = apk.abi.name.orEmpty(), + fullUrl = apk.url, + fileName = apk.fileName, + ) + private fun getLatestApks(apks: List): List { if (apks.isEmpty()) { return emptyList() @@ -266,6 +445,7 @@ class HomeViewModel( installedDate = appState?.formattedInstallDate, installingPackageName = appState?.installingPackageName, splitNames = appState?.splitNames ?: emptyList(), + installedTryBuild = appState?.takeIf { repository.appName == FENIX_DEBUG }?.let(::matchingInstalledTryBuild), apks = apksResult, selectedReleaseVersion = selectedReleaseVersion, availableReleaseVersions = availableReleaseVersions, @@ -274,7 +454,8 @@ class HomeViewModel( private fun convertParsedApksToUiModels(parsedApks: List): List { return parsedApks.map { parsedApk -> - val date = parsedApk.rawDateString?.formatApkDate() + val date = parsedApk.rawDateString?.formatNightlyBuildDate() + val buildDate = parsedApk.rawDateString?.rawNightlyBuildDate() val isCompatible = supportedAbis.any { deviceAbi -> deviceAbi.equals( parsedApk.abiName, @@ -306,6 +487,7 @@ class HomeViewModel( ApkUiModel( originalString = parsedApk.originalString, date = date ?: "", + buildDate = buildDate, appName = parsedApk.appName, version = parsedApk.version, abi = AbiUiModel(parsedApk.abiName, isCompatible), @@ -318,116 +500,32 @@ class HomeViewModel( } } - // Converts a raw build timestamp "yyyy-MM-dd-HH-mm-ss" to the display form - // "yyyy-MM-dd HH:mm:ss". Returns the input unchanged if it isn't in that shape. - private fun String.formatApkDate(): String { - val parts = split("-") - return if (parts.size >= 6) { - "${parts[0]}-${parts[1]}-${parts[2]} ${parts[3]}:${parts[4]}:${parts[5]}" - } else { - this - } - } - - private fun updateApkDownloadStateInScreenState( - appName: String, - uniqueKey: String, - newDownloadState: DownloadState, - ) { - _homeScreenState.update { currentState -> - if (currentState !is HomeScreenState.Loaded) return@update currentState - - val updatedApps = currentState.apps.toMutableMap() - var updatedTryFoxApp = currentState.tryfoxApp - - if (appName == TRYFOX) { - updatedTryFoxApp = updatedTryFoxApp?.let { appToUpdate -> - val apksResult = - appToUpdate.apks as? ApksResult.Success ?: return@let appToUpdate - val updatedApks = apksResult.apks.map { - if (it.uniqueKey == uniqueKey) it.copy(downloadState = newDownloadState) else it - } - appToUpdate.copy(apks = ApksResult.Success(updatedApks)) - } - } else { - val appToUpdate = updatedApps[appName] ?: return@update currentState - val apksResult = - appToUpdate.apks as? ApksResult.Success ?: return@update currentState - - val updatedApks = apksResult.apks.map { - if (it.uniqueKey == uniqueKey) it.copy(downloadState = newDownloadState) else it - } - updatedApps[appName] = appToUpdate.copy(apks = ApksResult.Success(updatedApks)) - } - - val isDownloading = updatedApps.values.any { app -> - (app.apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true - } || (updatedTryFoxApp?.apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true - - currentState.copy( - apps = updatedApps, - tryfoxApp = updatedTryFoxApp, - isDownloadingAnyFile = isDownloading, - ) - } - } - fun downloadNightlyApk(apkInfo: ApkUiModel) { if (apkInfo.downloadState is DownloadState.InProgress || apkInfo.downloadState is DownloadState.Downloaded) { return } - viewModelScope.launch(ioDispatcher) { - updateApkDownloadStateInScreenState( - apkInfo.appName, - apkInfo.uniqueKey, - DownloadState.InProgress(0f, isIndeterminate = true), - ) - - val outputDir = apkInfo.apkDir - if (!outputDir.exists()) outputDir.mkdirs() - val outputFile = File(outputDir, apkInfo.fileName) - - val result = downloadFileRepository.downloadFile( + val outputFile = File(apkInfo.apkDir, apkInfo.fileName) + outputFile.parentFile?.mkdirs() + downloadCoordinator.enqueue( + ApkDownloadRequest( + uniqueKey = apkInfo.uniqueKey, downloadUrl = apkInfo.url, outputFile = outputFile, - onProgress = { bytesDownloaded, totalBytes -> - val isIndeterminate = totalBytes <= 0 - val progress = - if (isIndeterminate) 0f else bytesDownloaded.toFloat() / totalBytes.toFloat() - updateApkDownloadStateInScreenState( - apkInfo.appName, - apkInfo.uniqueKey, - DownloadState.InProgress(progress, isIndeterminate), - ) - }, - ) - - when (result) { - is NetworkResult.Success -> { - updateApkDownloadStateInScreenState( - apkInfo.appName, - apkInfo.uniqueKey, - DownloadState.Downloaded(result.data), - ) - cacheManager.checkCacheStatus() // Notify cache manager about new file - installApk(result.data) - } - - is NetworkResult.Error -> { - updateApkDownloadStateInScreenState( - apkInfo.appName, - apkInfo.uniqueKey, - DownloadState.DownloadFailed(result.message), - ) - cacheManager.checkCacheStatus() // Check cache even on error - } - } - } + appName = apkInfo.appName, + fileName = apkInfo.fileName, + cacheRelativePath = cacheRelativePathFor(apkInfo), + ), + ) } fun installApk(file: File) { - intentManager.installApk(file) + installCoordinator.install(file.absolutePath, file) + } + + fun installHomeApk(apkInfo: ApkUiModel) { + val file = File(apkInfo.apkDir, apkInfo.fileName) + installCoordinator.install(apkInfo.uniqueKey, file) } fun uninstallApp(packageName: String) { @@ -467,6 +565,11 @@ class HomeViewModel( val builds = pendingBuildsByApp.remove(appName) ?: return val chosen = builds.filter { it.rawDateString == buildId } if (chosen.isEmpty()) return + val chosenApks = ApksResult.Success(convertParsedApksToUiModels(chosen)) + + updateCurrentApp(appName) { + it.copy(apks = chosenApks, pendingBuildOptions = emptyList()) + } _homeScreenState.update { state -> if (state !is HomeScreenState.Loaded) return@update state @@ -474,7 +577,7 @@ class HomeViewModel( state.copy( apps = state.apps + ( appName to app.copy( - apks = ApksResult.Success(convertParsedApksToUiModels(chosen)), + apks = chosenApks, pendingBuildOptions = emptyList(), ) ), @@ -485,6 +588,7 @@ class HomeViewModel( /** Dismisses the multi-build prompt, leaving the latest build (already shown) in place. */ fun onDismissBuildPicker(appName: String) { pendingBuildsByApp.remove(appName) + updateCurrentApp(appName) { app -> app.copy(pendingBuildOptions = emptyList()) } _homeScreenState.update { state -> if (state !is HomeScreenState.Loaded) return@update state val app = state.apps[appName] ?: return@update state @@ -499,7 +603,7 @@ class HomeViewModel( .distinct() // rawDateString is "yyyy-MM-dd-HH-mm-ss" (UTC), so lexical descending == newest first. return timestamps.sortedDescending().map { rawDate -> - NightlyBuildOption(id = rawDate, label = rawDate.formatApkDate()) + NightlyBuildOption(id = rawDate, label = rawDate.formatNightlyBuildTimestamp()) } } @@ -517,6 +621,9 @@ class HomeViewModel( apks = ApksResult.Loading, selectedReleaseVersion = version, ) + updateCurrentApp(appName) { + it.copy(apks = ApksResult.Loading, selectedReleaseVersion = version) + } _homeScreenState.value = currentState.copy(apps = updatedApps) val newApksResult = repository.getReleasesForVersion(version).toApksResult(appName) @@ -528,8 +635,12 @@ class HomeViewModel( apks = newApksResult, selectedReleaseVersion = version, ) + updateCurrentApp(appName) { + it.copy(apks = newApksResult, selectedReleaseVersion = version) + } _homeScreenState.value = latestState.copy(apps = finalUpdatedApps) + syncLoadedStateDownloadStates() } } @@ -550,6 +661,13 @@ class HomeViewModel( apks = ApksResult.Loading, pendingBuildOptions = emptyList(), ) + updateCurrentApp(appName) { + it.copy( + userPickedDate = date, + apks = ApksResult.Loading, + pendingBuildOptions = emptyList(), + ) + } _homeScreenState.value = currentState.copy(apps = updatedApps) @@ -581,7 +699,15 @@ class HomeViewModel( val finalUpdatedApps = latestState.apps.toMutableMap() finalUpdatedApps[appName] = finalUpdatedApp + updateCurrentApp(appName) { + it.copy( + userPickedDate = date, + apks = newApksResult, + pendingBuildOptions = buildOptions, + ) + } _homeScreenState.value = latestState.copy(apps = finalUpdatedApps) + syncLoadedStateDownloadStates() } } @@ -607,7 +733,29 @@ class HomeViewModel( mozillaPackageManager.launchApp(app) } + fun openInstalledApp(packageName: String) { + installCoordinator?.openInstalledApp(packageName) ?: mozillaPackageManager.launchApp(packageName) + } + + private fun matchingInstalledTryBuild(appState: AppState): InstalledTryBuild? = + matchingInstalledTryBuild(appState.packageName, appState.version, appState.versionCode) + + private fun matchingInstalledTryBuild( + packageName: String, + versionName: String?, + versionCode: Long?, + ): InstalledTryBuild? { + val build = installedTryBuild ?: return null + if (versionName == null || build.versionName == null) return null + return build.takeIf { + packageName == it.packageName && + versionName == it.versionName && + versionCode == it.versionCode + } + } + fun dismissTryFoxCard() { + tryFoxCardDismissed = true _homeScreenState.update { currentState -> if (currentState !is HomeScreenState.Loaded) return@update currentState currentState.copy(tryfoxApp = null) @@ -627,6 +775,77 @@ class HomeViewModel( } } + private fun syncLoadedStateDownloadStates() { + val persistedDownloads = downloadStates.value + _homeScreenState.update { currentState -> + if (currentState !is HomeScreenState.Loaded) return@update currentState + currentState.applyDownloadStates(persistedDownloads) + } + } + + private fun HomeScreenState.Loaded.applyDownloadStates( + persistedDownloads: Map, + ): HomeScreenState.Loaded { + val updatedApps = apps.mapValues { (_, app) -> app.withDownloadStates(persistedDownloads) } + val updatedTryFoxApp = tryfoxApp?.withDownloadStates(persistedDownloads) + val isDownloading = updatedApps.values.any { app -> app.containsActiveDownload() } || + updatedTryFoxApp?.containsActiveDownload() == true + + return copy( + apps = updatedApps, + tryfoxApp = updatedTryFoxApp, + isDownloadingAnyFile = isDownloading, + ) + } + + private fun AppUiModel.withDownloadStates( + persistedDownloads: Map, + ): AppUiModel { + val apksResult = apks as? ApksResult.Success ?: return this + val updatedApks = apksResult.apks.map { apk -> + apk.copy(downloadState = resolveDownloadState(apk, persistedDownloads)) + } + return copy(apks = ApksResult.Success(updatedApks)) + } + + private fun AppUiModel.containsActiveDownload(): Boolean = + (apks as? ApksResult.Success)?.apks?.any { it.downloadState is DownloadState.InProgress } == true + + private fun resolveDownloadState( + apk: ApkUiModel, + persistedDownloads: Map, + ): DownloadState { + val resolvedFile = File(apk.apkDir, apk.fileName) + return persistedDownloads[apk.uniqueKey]?.toDownloadState(resolvedFile) + ?: if (resolvedFile.exists()) { + DownloadState.Downloaded(resolvedFile) + } else { + DownloadState.NotDownloaded + } + } + + private fun PersistedDownloadState.toDownloadState(file: File): DownloadState = + when (status) { + DownloadStatus.QUEUED, + DownloadStatus.RUNNING, + -> DownloadState.InProgress( + progress = progress ?: 0f, + isIndeterminate = totalBytes <= 0L, + ) + DownloadStatus.SUCCEEDED -> if (file.exists()) { + DownloadState.Downloaded(file) + } else { + DownloadState.NotDownloaded + } + DownloadStatus.FAILED -> DownloadState.DownloadFailed(errorMessage) + DownloadStatus.CANCELED -> DownloadState.NotDownloaded + } + + private fun cacheRelativePathFor(apkInfo: ApkUiModel): String? { + val cacheRoot = cacheManager.getCacheDir(apkInfo.appName).parentFile ?: return null + return apkInfo.apkDir.relativeToOrNull(cacheRoot)?.path + } + companion object { private const val TAG = "HomeViewModel" } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatter.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatter.kt new file mode 100644 index 0000000..6b9bb72 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatter.kt @@ -0,0 +1,42 @@ +package org.mozilla.tryfox.ui.screens + +import kotlinx.datetime.Clock +import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.LocalDate +import kotlinx.datetime.TimeZone +import kotlinx.datetime.minus +import kotlinx.datetime.todayIn + +/** Formats a Nightly build timestamp for its date chip on Home. */ +internal fun String.formatNightlyBuildDate( + today: LocalDate = Clock.System.todayIn(TimeZone.currentSystemDefault()), +): String { + val relativeDate = when (rawNightlyBuildDate()) { + today -> "Today" + today.minus(1, DateTimeUnit.DAY) -> "Yesterday" + else -> return formatNightlyBuildTimestamp() + } + val time = formatNightlyBuildTimestamp().substringAfter(" ", missingDelimiterValue = "") + return if (time.isBlank()) relativeDate else "$relativeDate $time" +} + +/** Parses the calendar day encoded by an archive Nightly timestamp. */ +internal fun String.rawNightlyBuildDate(): LocalDate? { + val parts = substringBefore(" ").split("-") + if (parts.size < 3) return null + + return try { + LocalDate(parts[0].toInt(), parts[1].toInt(), parts[2].toInt()) + } catch (_: IllegalArgumentException) { + null + } +} + +internal fun String.formatNightlyBuildTimestamp(): String { + val parts = split("-") + return if (parts.size >= 6) { + "${parts[0]}-${parts[1]}-${parts[2]} ${parts[3]}:${parts[4]}" + } else { + this + } +} 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 deleted file mode 100644 index 5c6bd6e..0000000 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/ProfileScreen.kt +++ /dev/null @@ -1,379 +0,0 @@ -package org.mozilla.tryfox.ui.screens - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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 -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 -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.ElevatedCard -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.PlainTooltip -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTooltipState -import androidx.compose.runtime.Composable -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 -import androidx.compose.ui.Modifier -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 -import androidx.compose.ui.unit.dp -import org.mozilla.tryfox.R -import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.model.CacheManagementState -import org.mozilla.tryfox.ui.composables.AppIcon -import org.mozilla.tryfox.ui.composables.BinButton -import org.mozilla.tryfox.ui.composables.DownloadButton -import org.mozilla.tryfox.ui.composables.ErrorState -import org.mozilla.tryfox.ui.composables.PushCommentCard -import org.mozilla.tryfox.ui.models.JobDetailsUiModel -import org.mozilla.tryfox.util.FENIX -import org.mozilla.tryfox.util.FENIX_NIGHTLY -import org.mozilla.tryfox.util.FOCUS -import org.mozilla.tryfox.util.FOCUS_RELEASE -import java.util.Locale - -// Helper function to format app name for display -private fun formatAppNameForDisplay(appName: String): String { - return when (appName.lowercase(Locale.getDefault())) { - FENIX_NIGHTLY -> "Fenix Nightly" - FENIX -> "Fenix" - FOCUS -> "Focus Nightly" - FOCUS_RELEASE -> "Focus Release" - else -> appName.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } - } -} - -@Composable -private fun ProfileSearchButton( - onClick: () -> Unit, - enabled: Boolean, - isLoading: Boolean, - modifier: Modifier = Modifier, -) { - Button( - onClick = onClick, - enabled = enabled, - modifier = modifier.testTag("profile_search_button"), - shape = RoundedCornerShape(topStart = 0.dp, bottomStart = 0.dp, topEnd = 8.dp, bottomEnd = 8.dp), - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(0.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp).padding(horizontal = 12.dp), - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - Icons.Default.Search, - contentDescription = stringResource(id = R.string.profile_screen_search_button_description), - tint = MaterialTheme.colorScheme.onPrimary, - modifier = Modifier.padding(horizontal = 12.dp), - ) - } - } -} - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -private fun UserSearchCard( - email: String, - onEmailChange: (String) -> Unit, - onSearchClick: () -> Unit, - isLoading: Boolean, - modifier: Modifier = Modifier, -) { - val keyboardController = LocalSoftwareKeyboardController.current - - androidx.compose.material3.Card( - modifier = modifier.fillMaxWidth(), - elevation = CardDefaults.cardElevation(defaultElevation = 4.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(id = R.string.profile_screen_search_card_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min), - verticalAlignment = Alignment.CenterVertically, - ) { - OutlinedTextField( - value = email, - onValueChange = onEmailChange, - label = { Text(stringResource(id = R.string.profile_screen_user_email_label)) }, - modifier = Modifier - .weight(1f) - .testTag("profile_email_input"), - singleLine = true, - shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp, topEnd = 0.dp, bottomEnd = 0.dp), - trailingIcon = { - if (email.isNotEmpty()) { - IconButton( - onClick = { onEmailChange("") }, - modifier = Modifier.testTag("profile_email_clear_button"), - ) { - Icon( - imageVector = Icons.Filled.Close, - contentDescription = stringResource(id = R.string.profile_screen_clear_email_description), - ) - } - } - }, - keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), - keyboardActions = KeyboardActions(onSearch = { - onSearchClick() // Perform the original search action - keyboardController?.hide() - }), - ) - ProfileSearchButton( - onClick = { - onSearchClick() // Perform the original search action - keyboardController?.hide() - }, - enabled = !isLoading && email.isNotBlank(), - isLoading = isLoading, - modifier = Modifier.fillMaxHeight().padding(top = 8.dp), - ) - } - } - } -} - -/** - * 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( - modifier: Modifier = Modifier, - onNavigateUp: () -> Unit, - profileViewModel: ProfileViewModel, -) { - val authorEmail by profileViewModel.authorEmail.collectAsState() - val pushes by profileViewModel.pushes.collectAsState() - val isLoading by profileViewModel.isLoading.collectAsState() - val errorMessage by profileViewModel.errorMessage.collectAsState() - val cacheState by profileViewModel.cacheState.collectAsState() - - val isDownloading = remember(pushes) { - pushes.any { push -> - push.jobs.any { job -> - job.artifacts.any { artifact -> - artifact.downloadState is DownloadState.InProgress - } - } - } - } - - Scaffold( - modifier = modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { Text(stringResource(id = R.string.profile_screen_title)) }, - navigationIcon = { - IconButton(onClick = onNavigateUp) { - Icon( - imageVector = Icons.AutoMirrored.Filled.ArrowBack, - contentDescription = stringResource(id = R.string.common_back_button_description), - ) - } - }, - actions = { - val tooltipState = rememberTooltipState() - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text(stringResource(id = R.string.bin_button_tooltip_clear_downloaded_apks)) - } - }, - state = tooltipState, - ) { - BinButton( - cacheState = cacheState, - onConfirm = { profileViewModel.clearAppCache() }, - enabled = !isDownloading && cacheState == CacheManagementState.IdleNonEmpty, - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - ), - ) - }, - ) { innerPadding -> - Column( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding) - .padding(horizontal = 16.dp) - .padding(top = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - UserSearchCard( - email = authorEmail, - onEmailChange = { profileViewModel.updateAuthorEmail(it) }, - onSearchClick = { profileViewModel.searchByAuthor() }, - isLoading = isLoading && pushes.isEmpty(), - ) - - when { - isLoading && pushes.isEmpty() && authorEmail.isNotBlank() -> { - Column( - modifier = Modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.Center, - ) { - CircularProgressIndicator() - Text( - text = stringResource(id = R.string.profile_screen_loading_pushes), - modifier = Modifier.padding(top = 8.dp), - ) - } - } - errorMessage != null -> { - ErrorState(errorMessage = errorMessage!!) - } - pushes.isNotEmpty() -> { - LazyColumn( - contentPadding = PaddingValues(bottom = 16.dp), - ) { - 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), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - PushCommentCard( - comment = push.pushComment, - author = push.author, - revision = push.revision ?: "unknown_revision", - pushTimestamp = push.pushTimestamp, - ) - push.jobs.forEach { job -> - JobCard(job = job, profileViewModel = profileViewModel) - } - } - } - } - } - } - !isLoading && errorMessage == null && pushes.isEmpty() -> { - Box( - modifier = Modifier.fillMaxWidth().padding(top = 16.dp), - contentAlignment = Alignment.Center, - ) { - val message = if (authorEmail.isBlank()) { - stringResource(id = R.string.profile_screen_no_pushes_enter_email) - } else { - stringResource(id = R.string.profile_screen_no_pushes_found) - } - Text(message) - } - } - } - } - } -} - -@Composable -private fun JobCard( - job: JobDetailsUiModel, - profileViewModel: ProfileViewModel, -) { - val appNameForIconAndLogic = job.appName - val displayAppName = formatAppNameForDisplay(appNameForIconAndLogic) - val apk = remember(job.artifacts) { - job.artifacts.firstOrNull { it.abi.isSupported } - } - - androidx.compose.material3.Card( - modifier = Modifier - .fillMaxWidth() - .padding(vertical = 4.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceVariant), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth(), - ) { - AppIcon(appName = appNameForIconAndLogic, modifier = Modifier.size(40.dp)) - Spacer(Modifier.width(8.dp)) - Text( - text = displayAppName, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - ) - Spacer(Modifier.weight(1f)) - apk?.let { - DownloadButton( - downloadState = it.downloadState, - onDownloadClick = { profileViewModel.downloadArtifact(it) }, - 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 2f70e5d..f0aa4a3 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 @@ -3,26 +3,37 @@ package org.mozilla.tryfox.ui.screens import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit import logcat.LogPriority import logcat.logcat import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.RevisionDetail +import org.mozilla.tryfox.data.RevisionResult import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.CacheManager -import org.mozilla.tryfox.data.managers.IntentManager -import org.mozilla.tryfox.data.repositories.DownloadFileRepository import org.mozilla.tryfox.data.repositories.HistoryRepository import org.mozilla.tryfox.data.repositories.TreeherderRepository import org.mozilla.tryfox.data.repositories.UserDataRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.InstallState +import org.mozilla.tryfox.install.TryBuildProvenance import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ArtifactUiModel @@ -30,6 +41,81 @@ import org.mozilla.tryfox.ui.models.JobDetailsUiModel import org.mozilla.tryfox.ui.models.PushUiModel import org.mozilla.tryfox.util.TREEHERDER import java.io.File +import java.util.Locale + +private fun RevisionDetail.isTryTriggeringCommit(): Boolean = + comments.trimStart().startsWith("Fuzzy query=", ignoreCase = true) || + comments.contains("Pushed via `mach try", ignoreCase = true) + +private fun firstRealCommitComment(revisions: List): String? = + revisions.firstOrNull { revision -> + revision.comments.isNotBlank() && !revision.isTryTriggeringCommit() + }?.comments + +internal fun selectPreferredPushComment( + revisions: List, + precedingPushRevisions: List> = emptyList(), +): String = + firstRealCommitComment(revisions) + ?: precedingPushRevisions.firstNotNullOfOrNull(::firstRealCommitComment) + ?: revisions.firstOrNull()?.comments.orEmpty().ifBlank { "No comment" } + +/** True when a Try-generated push needs an earlier push to provide a useful title. */ +internal fun needsPrecedingRealCommit(revisions: List): Boolean = + firstRealCommitComment(revisions) == null && revisions.any(RevisionDetail::isTryTriggeringCommit) + +private val unsignedApkJobNamePattern = Regex("^build-apk-(.+)$", RegexOption.IGNORE_CASE) +private val apkJobNameHints = listOf("signing-apk", "android-apk", "apk-focus", "apk-fenix", "apk-reference-browser", "apk-geckoview") +private val nonAndroidPlatformHints = listOf("ios", "mac", "macos", "macosx", "win", "windows", "linux", "desktop") +private val androidProductHints = listOf("focus", "fenix", "reference-browser", "geckoview", "roam", "android") + +internal fun isAndroidApkCandidate(job: org.mozilla.tryfox.data.JobDetails): Boolean { + if (job.isTest) return false + val appName = job.appName.lowercase() + val jobName = job.jobName.lowercase() + val hasAndroidSource = jobName.contains("build-android") || + androidProductHints.any { hint -> jobName.contains(hint) || appName == hint } + if (!hasAndroidSource || nonAndroidPlatformHints.any(jobName::contains)) return false + return apkJobNameHints.any(jobName::contains) || jobName.contains("apk") +} + +/** Removes an unsigned build only when its corresponding signed build has a displayable APK. */ +internal fun filterRedundantUnsignedApkJobs(jobs: List): List { + val availableSignedJobNames = jobs.asSequence() + .filter(JobDetailsUiModel::isSignedBuild) + .map { it.jobName.trim().lowercase() } + .toSet() + + return jobs.filter { job -> + if (job.isSignedBuild) return@filter true + val signedEquivalent = unsignedApkJobNamePattern.matchEntire(job.jobName.trim()) + ?.groupValues + ?.get(1) + ?.let { "signing-apk-$it" } + ?.lowercase() + signedEquivalent == null || signedEquivalent !in availableSignedJobNames + } +} + +/** Orders displayed APK jobs by variant group, then app and job name. */ +internal fun orderApkJobs(jobs: List): List = + jobs.sortedWith( + compareBy( + { job -> apkJobCategory(job) }, + { job -> job.appName.lowercase(Locale.ROOT) }, + { job -> job.jobName.lowercase(Locale.ROOT) }, + JobDetailsUiModel::taskId, + ), + ) + +private fun apkJobCategory(job: JobDetailsUiModel): Int { + val name = job.jobName.lowercase(Locale.ROOT) + return when { + "perftest" in name || "simulation" in name -> 2 + "firebase" in name -> 1 + else -> 0 + } +} /** * ViewModel for the Profile screen, responsible for fetching pushes and artifacts by author, managing downloads, and handling user interactions. @@ -40,39 +126,137 @@ import java.io.File * @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( +/** + * State holder for every Treeherder search. The input is classified once at submission + * time; from that point on the presentation only consumes [pushes], regardless of whether + * the repository request was made by revision or by author email. + */ +class SearchViewModel( private val fenixRepository: TreeherderRepository, - private val downloadFileRepository: DownloadFileRepository, private val userDataRepository: UserDataRepository, private val cacheManager: CacheManager, - private val intentManager: IntentManager, private val historyRepository: HistoryRepository, + private val downloadCoordinator: ApkDownloadCoordinator, + private val installCoordinator: ApkInstallCoordinator, authorEmail: String?, private val currentTimeMillisProvider: () -> Long = System::currentTimeMillis, + project: String = "try", ) : ViewModel() { + private data class ArtifactLoadResult( + val artifacts: List, + val failed: Boolean, + ) + + private data class PushPageResult( + val pushes: List, + val hasExpiredJobs: Boolean, + ) + + private data class PushBuildResult( + val push: PushUiModel?, + val hasExpiredJobs: Boolean, + ) + companion object { - private const val TAG = "ProfileViewModel" + private const val TAG = "SearchViewModel" + private const val MAX_PARALLEL_ARTIFACT_REQUESTS = 6 + private const val PUSH_PAGE_SIZE = 20 + private const val EMPTY_PAGE_FALLBACK_SIZE = 50 } + private sealed interface PaginatedSearch { + val project: String + val queryToRecord: String? + + data class Author( + override val project: String, + val email: String, + ) : PaginatedSearch { + override val queryToRecord: String = email + } + + data class Recent(override val project: String) : PaginatedSearch { + override val queryToRecord: String? = null + } + } + + private data class PaginationSession( + val search: PaginatedSearch, + var nextOffset: Int = 0, + var hasMore: Boolean = true, + var hasReachedExpiredJobs: Boolean = false, + var authorPushTimestampCursor: Long? = null, + var hasRecordedSearch: Boolean = false, + val loadedPushIds: MutableSet = mutableSetOf(), + ) + private val _authorEmail = MutableStateFlow(authorEmail ?: "") val authorEmail: StateFlow = _authorEmail.asStateFlow() + /** The shared search field value. Kept as an alias during the email API migration. */ + val query: StateFlow = _authorEmail.asStateFlow() + + private val _selectedProject = MutableStateFlow(project) + val selectedProject: StateFlow = _selectedProject.asStateFlow() + private val _isLoading = MutableStateFlow(false) val isLoading: StateFlow = _isLoading.asStateFlow() + private val _isLoadingMore = MutableStateFlow(false) + val isLoadingMore: StateFlow = _isLoadingMore.asStateFlow() + + private val _canLoadMore = MutableStateFlow(false) + val canLoadMore: StateFlow = _canLoadMore.asStateFlow() + + private val _loadMoreError = MutableStateFlow(null) + val loadMoreError: StateFlow = _loadMoreError.asStateFlow() + private val _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() + private val _warningMessage = MutableStateFlow(null) + val warningMessage: StateFlow = _warningMessage.asStateFlow() + + private val _hasReachedExpiredJobs = MutableStateFlow(false) + val hasReachedExpiredJobs: StateFlow = _hasReachedExpiredJobs.asStateFlow() + private val _pushes = MutableStateFlow>(emptyList()) val pushes: StateFlow> = _pushes.asStateFlow() + private val downloadStates = MutableStateFlow>(emptyMap()) val cacheState: StateFlow = cacheManager.cacheState + val searchHistory = userDataRepository.searchHistoryFlow + val installStates: StateFlow> = installCoordinator.states - private val deviceSupportedAbis: List by lazy { Build.SUPPORTED_ABIS.toList() } + private var paginationSession: PaginationSession? = null + private var activeSearchJob: Job? = null + + private val deviceSupportedAbis: List by lazy { + runCatching { Build.SUPPORTED_ABIS.toList() }.getOrDefault(emptyList()) + } init { - logcat(LogPriority.DEBUG, TAG) { "Initializing ProfileViewModel for email: $authorEmail" } + logcat(LogPriority.DEBUG, TAG) { "Initializing SearchViewModel for query: $authorEmail" } + downloadCoordinator.downloads + .onEach { persistedDownloads -> + downloadStates.value = persistedDownloads + syncLoadedStateDownloadStates() + } + .launchIn(viewModelScope) + + installCoordinator.successfulInstalls + .onEach { artifactKey -> + findArtifact(artifactKey)?.let { downloadedArtifact -> + try { + updateInstallTimestamp(downloadedArtifact) + } catch (_: Exception) { + // History is best-effort; installation has already succeeded. + } + } + } + .launchIn(viewModelScope) + cacheManager.cacheState.onEach { state -> if (state is CacheManagementState.IdleEmpty) { val updatedPushes = _pushes.value.map { @@ -84,143 +268,427 @@ class ProfileViewModel( }, ) }, + unsignedJobs = it.unsignedJobs.map { job -> + job.copy( + artifacts = job.artifacts.map { artifact -> + artifact.copy(downloadState = DownloadState.NotDownloaded) + }, + ) + }, ) } _pushes.value = updatedPushes + syncLoadedStateDownloadStates() } }.launchIn(viewModelScope) - if (authorEmail != null) { - searchByAuthor() - } else { - loadLastSearchedEmail() - } - } - - private fun loadLastSearchedEmail() { - viewModelScope.launch { - val lastEmail = userDataRepository.lastSearchedEmailFlow.first() - if (lastEmail.isNotBlank()) { - _authorEmail.value = lastEmail - logcat( - LogPriority.DEBUG, - TAG, - ) { "Initial author email loaded from storage: ${_authorEmail.value}" } - } - } + // Screen navigation only supplies a prefill. Searches are submitted explicitly by + // the screen (including its intentional deep-link effect), never during creation. } fun updateAuthorEmail(email: String) { logcat(LogPriority.DEBUG, TAG) { "Updating author email to: $email" } _authorEmail.value = email + _errorMessage.value = null + _warningMessage.value = null + _hasReachedExpiredJobs.value = false } - fun searchByAuthor() { - 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 + fun updateQuery(query: String) = updateAuthorEmail(query) + + fun updateSelectedProject(project: String) { + _selectedProject.value = project + _warningMessage.value = null + _hasReachedExpiredJobs.value = false + } + + fun setEmailFromDeepLinkAndSearch(project: String?, email: String) { + _selectedProject.value = project ?: "try" + _authorEmail.value = email + searchByAuthor() + } + + /** Applies either kind of deep link to the same model and executes the matching request. */ + fun setQueryFromDeepLinkAndSearch(project: String?, query: String) { + _selectedProject.value = project ?: "try" + _authorEmail.value = query + submitSearch() + } + + /** + * The sole search entry point used by the shared screen. Query kind changes only the + * Treeherder operation; loading, errors, results and artifact actions stay shared. + */ + fun submitSearch() { + when (val parsed = SearchQueryClassifier.classify(_authorEmail.value).getOrNull()) { + is SearchQuery.Email -> searchByAuthor() + is SearchQuery.Revision -> searchByRevision(parsed.value) + SearchQuery.RecentPushes -> searchRecentPushes() + null -> Unit } - viewModelScope.launch { - userDataRepository.saveLastSearchedEmail(emailToSearch) - _isLoading.value = true - _errorMessage.value = null - _pushes.value = emptyList() - logcat(LogPriority.DEBUG, TAG) { "Starting search..." } + } - when (val result = fenixRepository.getPushesByAuthor(emailToSearch)) { + private fun searchByRevision(revision: String) { + if (revision.isBlank()) { + _errorMessage.value = "Please enter a revision to search." + return + } + val projectToSearch = _selectedProject.value + // A revision search is deliberately non-paginated and invalidates any prior session. + activeSearchJob?.cancel() + paginationSession = null + _isLoadingMore.value = false + _canLoadMore.value = false + _loadMoreError.value = null + _hasReachedExpiredJobs.value = false + activeSearchJob = viewModelScope.launch { + try { + _isLoading.value = true + _errorMessage.value = null + _warningMessage.value = null + _hasReachedExpiredJobs.value = false + _pushes.value = emptyList() + when (val pushResult = fenixRepository.getPushByRevision(projectToSearch, revision)) { is NetworkResult.Success -> { - 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 } - if (filteredJobs.isNotEmpty()) { - val jobsWithArtifacts = filteredJobs.map { jobDetails -> - async { - val artifacts = fetchArtifacts(jobDetails.taskId) - if (artifacts.isNotEmpty()) { - JobDetailsUiModel( - appName = jobDetails.appName, - jobName = jobDetails.jobName, - jobSymbol = jobDetails.jobSymbol, - taskId = jobDetails.taskId, - isSignedBuild = jobDetails.isSignedBuild, - isTest = jobDetails.isTest, - artifacts = artifacts, - ) - } else { - null - } - } - }.awaitAll().filterNotNull() - - if (jobsWithArtifacts.isNotEmpty()) { - var determinedPushComment: String? = null - for (revDetail in pushResult.revisions) { - if (revDetail.comments.startsWith("Bug ")) { - determinedPushComment = revDetail.comments - break - } - } - if (determinedPushComment == null) { - determinedPushComment = pushResult.revisions.firstOrNull()?.comments - ?: "No comment" - } - PushUiModel( - pushComment = determinedPushComment, - author = pushResult.author, - jobs = jobsWithArtifacts, - revision = pushResult.revision, - pushTimestamp = pushResult.pushTimestamp, + val push = pushResult.data.results.firstOrNull() + if (push == null) { + _errorMessage.value = "No push found for project: $projectToSearch, revision: $revision" + } else { + val jobsResult = fenixRepository.getJobsForPush(push.id) + if (jobsResult is NetworkResult.Success) { + val (signedCandidates, unsignedCandidates) = selectJobsBySigning( + jobsResult.data.results.filter(::isAndroidApkCandidate), + ) + val jobs = mutableListOf() + for (job in signedCandidates + unsignedCandidates) { + loadJob(job)?.let(jobs::add) + } + val visibleJobs = filterRedundantUnsignedApkJobs(jobs) + val signedJobs = orderApkJobs(visibleJobs.filter(JobDetailsUiModel::isSignedBuild)) + val unsignedJobs = orderApkJobs(visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild)) + if (signedJobs.isEmpty() && unsignedJobs.isEmpty()) { + _errorMessage.value = "No APK builds found for this revision." + } else { + val precedingRevisions = if (needsPrecedingRealCommit(push.revisions)) { + when ( + val authorPushes = fenixRepository.getPushesByAuthor( + projectToSearch, + push.author, ) - } else { - logcat(LogPriority.VERBOSE, TAG) { - "No jobs with artifacts for push ID: ${pushResult.id}" + ) { + is NetworkResult.Success -> { + val pushIndex = authorPushes.data.results.indexOfFirst { it.id == push.id } + authorPushes.data.results + .take(pushIndex.coerceAtLeast(0)) + .asReversed() + .map { it.revisions } } - null + is NetworkResult.Error -> emptyList() } } else { - 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 + emptyList() } - null + _pushes.value = listOf( + PushUiModel( + project = projectToSearch, + pushComment = selectPreferredPushComment(push.revisions, precedingRevisions), + author = push.author, + jobs = signedJobs, + unsignedJobs = unsignedJobs, + revision = push.revision, + pushTimestamp = push.pushTimestamp, + ), + ) + syncLoadedStateDownloadStates() + userDataRepository.recordSearch(projectToSearch, revision) } + } else { + _errorMessage.value = "Error fetching jobs: ${(jobsResult as NetworkResult.Error).message}" } - }.awaitAll().filterNotNull() - - _pushes.value = pushesWithJobsAndArtifacts - logcat(TAG) { "Search finished, ${_pushes.value.size} pushes with artifacts found." } - if (pushesWithJobsAndArtifacts.isEmpty()) { - _errorMessage.value = "No signed builds found for this author." - 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}" + _errorMessage.value = "Error fetching revision details for $projectToSearch: ${pushResult.message}" + } + } + } finally { + if (activeSearchJob === currentCoroutineContext()[Job]) { + _isLoading.value = false + } + } + } + } + + fun searchByAuthor() { + val emailToSearch = _authorEmail.value + val projectToSearch = _selectedProject.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 + } + if (SearchQueryClassifier.classify(emailToSearch).getOrNull() !is SearchQuery.Email) { + return + } + startPaginatedSearch(PaginatedSearch.Author(projectToSearch, emailToSearch)) + } + + private fun searchRecentPushes() { + startPaginatedSearch(PaginatedSearch.Recent(_selectedProject.value)) + } + + private fun startPaginatedSearch(search: PaginatedSearch) { + activeSearchJob?.cancel() + activeSearchJob = viewModelScope.launch { + try { + _isLoading.value = true + _isLoadingMore.value = false + _errorMessage.value = null + _loadMoreError.value = null + _warningMessage.value = null + _hasReachedExpiredJobs.value = false + _pushes.value = emptyList() + paginationSession = PaginationSession(search) + _canLoadMore.value = true + loadPagesUntilResultsOrExhausted(paginationSession!!, isInitialLoad = true) + } finally { + if (activeSearchJob === currentCoroutineContext()[Job]) { + _isLoading.value = false } } - _isLoading.value = false } } - private suspend fun fetchArtifacts(taskId: String): List { + /** Called by the final result card when the user reaches the end of a paginated search. */ + fun loadMorePushes() { + val session = paginationSession ?: return + if (!session.hasMore || _isLoading.value || _isLoadingMore.value) return + activeSearchJob = viewModelScope.launch { + try { + _isLoadingMore.value = true + _loadMoreError.value = null + loadPagesUntilResultsOrExhausted(session, isInitialLoad = false) + } finally { + if (activeSearchJob === currentCoroutineContext()[Job]) { + _isLoadingMore.value = false + } + } + } + } + + fun retryLoadMorePushes() = loadMorePushes() + + private suspend fun loadPagesUntilResultsOrExhausted( + session: PaginationSession, + isInitialLoad: Boolean, + requestedCountOverride: Int? = null, + isEmptyPageFallback: Boolean = false, + ) { + if (!session.hasMore || paginationSession !== session) return + val requestedCount = requestedCountOverride ?: requestedPushCount(session.search, session.nextOffset) + logcat(LogPriority.DEBUG, TAG) { + "Requesting ${session.search::class.simpleName} push page: " + + "project=${session.search.project}, offset=${session.nextOffset}, count=$requestedCount" + } + when (val response = requestPushPage(session.search, session.nextOffset, requestedCount)) { + is NetworkResult.Error -> { + val message = "Error fetching pushes: ${response.message}" + logcat(LogPriority.ERROR, TAG) { message } + if (isEmptyPageFallback) return + if (isInitialLoad && _pushes.value.isEmpty()) _errorMessage.value = message else _loadMoreError.value = message + } + + is NetworkResult.Success -> { + if (paginationSession !== session) return + val rawPushes = response.data.results + logcat(LogPriority.DEBUG, TAG) { + "Received ${rawPushes.size} pushes at offset=${session.nextOffset}: " + + rawPushes.joinToString { "${it.id}:${it.revision.take(12)}" } + } + val newPushes = rawPushes.filter { session.loadedPushIds.add(it.id) } + // Treeherder's author filter ignores offset. Its timestamp cursor is inclusive, + // so duplicate the boundary push and remove it through loadedPushIds. + session.nextOffset += newPushes.size + if (session.search is PaginatedSearch.Author) { + session.authorPushTimestampCursor = rawPushes.lastOrNull()?.pushTimestamp + } + session.hasMore = rawPushes.size == requestedCount + _canLoadMore.value = session.hasMore + logcat(LogPriority.DEBUG, TAG) { + "Page contains ${newPushes.size} new pushes; hasMore=${session.hasMore}; " + + "loaded IDs=${session.loadedPushIds.size}" + } + if (rawPushes.isNotEmpty() && newPushes.isEmpty()) { + session.hasMore = false + _canLoadMore.value = false + logcat(LogPriority.WARN, TAG) { + "Treeherder returned only previously loaded pushes at offset=${session.nextOffset - rawPushes.size}; " + + "ending pagination." + } + updatePaginationWarning(session) + return + } + val pageResult = buildPushUiModels(session.search.project, newPushes, rawPushes) + val displayablePushes = pageResult.pushes + logcat(LogPriority.DEBUG, TAG) { + "Page produced ${displayablePushes.size} displayable pushes: " + + displayablePushes.joinToString { it.revision.orEmpty().take(12) } + } + if (displayablePushes.isNotEmpty()) { + _pushes.value += displayablePushes + syncLoadedStateDownloadStates() + if (!session.hasRecordedSearch) { + session.search.queryToRecord?.let { userDataRepository.recordSearch(session.search.project, it) } + session.hasRecordedSearch = true + } + } + if (pageResult.hasExpiredJobs) { + // Results are newest first. An all-empty jobs page marks Treeherder's job + // retention boundary, so older pushes cannot yield downloadable builds. + session.hasReachedExpiredJobs = true + _hasReachedExpiredJobs.value = true + session.hasMore = false + _canLoadMore.value = false + } + updatePaginationWarning(session) + // If a regular page has no usable APKs, make one larger request before asking + // the user to load more again. The fallback must never trigger another fallback. + if ( + !isEmptyPageFallback && + displayablePushes.isEmpty() && + session.hasMore + ) { + val fallbackCount = if (session.search is PaginatedSearch.Author) { + EMPTY_PAGE_FALLBACK_SIZE + 1 + } else { + EMPTY_PAGE_FALLBACK_SIZE + } + logcat(TAG) { "No usable APKs in this page; searching the next $EMPTY_PAGE_FALLBACK_SIZE." } + loadPagesUntilResultsOrExhausted( + session = session, + isInitialLoad = isInitialLoad, + requestedCountOverride = fallbackCount, + isEmptyPageFallback = true, + ) + return + } + if (_pushes.value.isEmpty() && !session.hasMore && !session.hasReachedExpiredJobs) { + _errorMessage.value = "No push was found with a job that produced an APK." + } + } + } + } + + private fun requestedPushCount(search: PaginatedSearch, offset: Int): Int = when (search) { + // The next author page includes its timestamp cursor, so request one additional push. + is PaginatedSearch.Author -> if (offset == 0) PUSH_PAGE_SIZE else PUSH_PAGE_SIZE + 1 + is PaginatedSearch.Recent -> PUSH_PAGE_SIZE + } + + private suspend fun requestPushPage( + search: PaginatedSearch, + offset: Int, + count: Int, + ) = when (search) { + is PaginatedSearch.Author -> fenixRepository.getPushesByAuthor( + project = search.project, + author = search.email, + count = count, + offset = 0, + pushTimestampLte = paginationSession?.takeIf { it.search == search }?.authorPushTimestampCursor, + ) + is PaginatedSearch.Recent -> fenixRepository.getRecentPushes( + project = search.project, + count = count, + offset = offset, + ) + } + + private fun updatePaginationWarning(session: PaginationSession) { + if (session.hasReachedExpiredJobs) { + _warningMessage.value = "Older pushes' jobs have expired." + } else { + _warningMessage.value = null + _hasReachedExpiredJobs.value = false + } + } + + private suspend fun buildPushUiModels( + project: String, + pushes: List, + pagePushes: List, + ): PushPageResult = coroutineScope { + val artifactSemaphore = Semaphore(MAX_PARALLEL_ARTIFACT_REQUESTS) + val pushResults = pushes.map { pushResult -> + async { + val jobsResult = fenixRepository.getJobsForPush(pushResult.id) + if (jobsResult !is NetworkResult.Success) { + logcat(LogPriority.WARN, TAG) { + "Push ${pushResult.id} job lookup failed: ${(jobsResult as NetworkResult.Error).message}" + } + return@async PushBuildResult(push = null, hasExpiredJobs = false) + } + val candidates = jobsResult.data.results.filter(::isAndroidApkCandidate) + logcat(LogPriority.DEBUG, TAG) { + "Push ${pushResult.id}:${pushResult.revision.take(12)} has " + + "${jobsResult.data.results.size} jobs and ${candidates.size} APK candidates: " + + candidates.joinToString { "${it.jobName} (${it.jobSymbol}, ${it.taskId})" } + } + val (signedCandidates, unsignedCandidates) = selectJobsBySigning( + candidates, + ) + val artifactResults = (signedCandidates + unsignedCandidates).map { jobDetails -> + async { + artifactSemaphore.withPermit { fetchArtifacts(jobDetails.taskId) } + } + }.awaitAll() + val jobsWithArtifacts = artifactResults.mapIndexedNotNull { index, artifactResult -> + artifactResult.artifacts.takeIf { it.isNotEmpty() } + ?.let { jobWithArtifacts((signedCandidates + unsignedCandidates)[index], it) } + } + val visibleJobs = filterRedundantUnsignedApkJobs(jobsWithArtifacts) + if (visibleJobs.isEmpty()) { + logcat(LogPriority.DEBUG, TAG) { + "Push ${pushResult.id} produced no visible jobs after artifact resolution. " + + "Candidates=${candidates.size}, jobsWithArtifacts=${jobsWithArtifacts.size}" + } + } + val push = visibleJobs.takeIf { it.isNotEmpty() }?.let { + logcat(LogPriority.DEBUG, TAG) { + "Push ${pushResult.id} is displayable with ${visibleJobs.size} jobs: " + + visibleJobs.joinToString { "${it.jobName} (${it.artifacts.size} APKs)" } + } + val index = pagePushes.indexOfFirst { pagePush -> pagePush.id == pushResult.id } + PushUiModel( + project = project, + pushComment = selectPreferredPushComment( + revisions = pushResult.revisions, + precedingPushRevisions = pagePushes.take(index.coerceAtLeast(0)).asReversed().map { it.revisions }, + ), + author = pushResult.author, + jobs = orderApkJobs(visibleJobs.filter(JobDetailsUiModel::isSignedBuild)), + unsignedJobs = orderApkJobs(visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild)), + revision = pushResult.revision, + pushTimestamp = pushResult.pushTimestamp, + ) + } + PushBuildResult( + push = push, + // Treeherder retains the push after its jobs have expired, but returns an + // otherwise successful response with no jobs. Since results are newest + // first, this is the boundary beyond which builds cannot be retrieved. + hasExpiredJobs = jobsResult.data.results.isEmpty(), + ) + } + }.awaitAll() + PushPageResult( + pushes = pushResults.mapNotNull(PushBuildResult::push), + hasExpiredJobs = pushResults.isNotEmpty() && pushResults.all(PushBuildResult::hasExpiredJobs), + ) + } + + private suspend fun fetchArtifacts(taskId: String): ArtifactLoadResult { logcat(LogPriority.DEBUG, TAG) { "fetchArtifacts called for taskId: $taskId" } return when (val artifactsResult = fenixRepository.getArtifactsForTask(taskId)) { is NetworkResult.Success -> { @@ -231,24 +699,29 @@ class ProfileViewModel( LogPriority.VERBOSE, TAG, ) { "Found ${filteredApks.size} APKs for taskId: $taskId" } - filteredApks.map { artifact -> - val artifactFileName = artifact.name.substringAfterLast('/') - val downloadedFile = getDownloadedFile(artifactFileName, taskId) - val downloadState = if (downloadedFile != null) { - DownloadState.Downloaded(downloadedFile) - } else { - DownloadState.NotDownloaded + // A task can expose several ABI variants. Surface only the first variant + // matching Android's device ABI preference order. + val selectedArtifact = deviceSupportedAbis.asSequence().mapNotNull { preferredAbi -> + filteredApks.firstOrNull { artifact -> + artifact.abi.equals(preferredAbi, ignoreCase = true) } - val isCompatible = - artifact.abi != null && deviceSupportedAbis.any { deviceAbi -> - deviceAbi.equals(artifact.abi, ignoreCase = true) - } + }.firstOrNull() ?: deviceSupportedAbis + .takeIf { it.isEmpty() } + ?.let { filteredApks.firstOrNull() } + val artifacts = selectedArtifact?.let { artifact -> listOf(artifact) }.orEmpty().map { artifact -> + val artifactFileName = artifact.name.substringAfterLast('/') + val uniqueKey = "$taskId/$artifactFileName" + val downloadState = resolveDownloadState( + artifactName = artifactFileName, + taskId = taskId, + uniqueKey = uniqueKey, + ) ArtifactUiModel( name = artifact.name, taskId = taskId, abi = AbiUiModel( name = artifact.abi, - isSupported = isCompatible, + isSupported = true, ), downloadUrl = artifact.getDownloadUrl(taskId), expires = artifact.expires, @@ -256,24 +729,57 @@ class ProfileViewModel( uniqueKey = "$taskId/${artifact.name.substringAfterLast('/')}", ) } + ArtifactLoadResult( + artifacts = artifacts, + failed = false, + ) } is NetworkResult.Error -> { logcat(LogPriority.WARN, TAG) { "fetchArtifacts error for taskId $taskId: ${artifactsResult.message}" } - emptyList() + ArtifactLoadResult(artifacts = emptyList(), failed = true) } } } + /** Keeps the existing signing-job preference while loading unsigned candidates as a separate group. */ + private fun selectJobsBySigning( + candidates: List, + ): Pair, List> { + val signedCandidates = candidates.filter { it.isSignedBuild } + val preferredSignedCandidates = signedCandidates + .filter { it.jobName.contains("signing-apk", ignoreCase = true) } + .ifEmpty { signedCandidates } + return preferredSignedCandidates to candidates.filterNot { it.isSignedBuild } + } + + private suspend fun loadJob(job: org.mozilla.tryfox.data.JobDetails): JobDetailsUiModel? { + val artifactResult = fetchArtifacts(job.taskId) + return artifactResult.artifacts.takeIf { it.isNotEmpty() }?.let { artifacts -> jobWithArtifacts(job, artifacts) } + } + + private fun jobWithArtifacts( + job: org.mozilla.tryfox.data.JobDetails, + artifacts: List, + ) = JobDetailsUiModel( + appName = job.appName, + jobName = job.jobName, + jobSymbol = job.jobSymbol, + taskId = job.taskId, + isSignedBuild = job.isSignedBuild, + isTest = job.isTest, + artifacts = artifacts, + ) + fun getDownloadedFile(artifactName: String, taskId: String): File? { if (taskId.isBlank()) return null - val taskSpecificDir = File(cacheManager.getCacheDir("treeherder"), taskId) + val taskSpecificDir = File(cacheManager.getCacheDir(TREEHERDER), taskId) val outputFile = File(taskSpecificDir, artifactName) val exists = outputFile.exists() logcat( - LogPriority.DEBUG, + LogPriority.VERBOSE, TAG, ) { "getDownloadedFile artifactName=$artifactName, taskId=$taskId, " + @@ -320,7 +826,7 @@ class ProfileViewModel( logcat( LogPriority.DEBUG, TAG, - ) { "Starting download coroutine for ${artifactUiModel.name}" } + ) { "Enqueuing WorkManager download for ${artifactUiModel.name}" } val downloadedArtifact = findArtifact(artifactUiModel.uniqueKey) if (downloadedArtifact != null) { try { @@ -331,10 +837,7 @@ class ProfileViewModel( } updateArtifactDownloadState(taskId, artifactUiModel.name, DownloadState.InProgress(0f)) - val downloadUrl = artifactUiModel.downloadUrl - logcat(LogPriority.DEBUG, TAG) { "Download URL: $downloadUrl" } - - val outputDir = File(cacheManager.getCacheDir("treeherder"), taskId) + val outputDir = File(cacheManager.getCacheDir(TREEHERDER), taskId) if (!outputDir.exists()) { outputDir.mkdirs() logcat( @@ -345,96 +848,55 @@ class ProfileViewModel( val outputFile = File(outputDir, artifactFileName) logcat(LogPriority.DEBUG, TAG) { "Output file: ${outputFile.absolutePath}" } - var lastLoggedNumericProgress = 0f - - logcat(TAG) { "Calling fenixRepository.downloadArtifact for ${artifactUiModel.name}" } - val result = downloadFileRepository.downloadFile( - downloadUrl = downloadUrl, + val request = ApkDownloadRequest( + uniqueKey = artifactUiModel.uniqueKey, + downloadUrl = artifactUiModel.downloadUrl, outputFile = outputFile, - onProgress = { bytesDownloaded, totalBytes -> - val currentProgressFloat = if (totalBytes > 0) { - bytesDownloaded.toFloat() / totalBytes.toFloat() - } else { - 0f - } - - var shouldLog = false - if (bytesDownloaded == 0L) { - shouldLog = true - lastLoggedNumericProgress = 0f - } else if (bytesDownloaded == totalBytes) { - shouldLog = true - lastLoggedNumericProgress = currentProgressFloat - } else if (currentProgressFloat - lastLoggedNumericProgress >= 0.02f) { - shouldLog = true - lastLoggedNumericProgress = currentProgressFloat - } - - if (shouldLog) { - logcat(LogPriority.VERBOSE, TAG) { - "Download progress for ${artifactUiModel.name}: $bytesDownloaded / $totalBytes " + - "($currentProgressFloat)" - } - } - updateArtifactDownloadState( - taskId, - artifactUiModel.name, - DownloadState.InProgress(currentProgressFloat), - ) - }, + appName = TREEHERDER, + fileName = artifactFileName, + cacheRelativePath = "$TREEHERDER/$taskId/$artifactFileName", ) - logcat(TAG) { "fenixRepository.downloadArtifact result for ${artifactUiModel.name}: $result" } - when (result) { - is NetworkResult.Success -> { - updateArtifactDownloadState( - taskId, - artifactUiModel.name, - DownloadState.Downloaded(result.data), - ) - cacheManager.checkCacheStatus() - logcat(TAG) { "Download success for ${artifactUiModel.name}. APK is ready to be installed." } - installApk(result.data) + try { + val workId = downloadCoordinator.enqueue(request) + logcat(LogPriority.DEBUG, TAG) { + "Download enqueued uniqueKey=${artifactUiModel.uniqueKey} workId=$workId " + + "outputPath=${outputFile.absolutePath}" } - - is NetworkResult.Error -> { - val failureMessage = "Download failed for $artifactFileName: ${result.message}" - if (result.cause != null) { - 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), - ) - cacheManager.checkCacheStatus() + downloadStates.value = downloadCoordinator.downloads.value + syncLoadedStateDownloadStates() + } catch (e: Exception) { + logcat(LogPriority.ERROR, TAG) { + "Failed to enqueue download for ${artifactUiModel.name}: ${e.message}" } + updateArtifactDownloadState( + taskId, + artifactUiModel.name, + DownloadState.DownloadFailed(e.message), + ) + cacheManager.checkCacheStatus() } } } - fun installApk(file: File) { - val downloadedArtifact = findDownloadedArtifact(file) - if (downloadedArtifact == null) { - intentManager.installApk(file) - return - } - - viewModelScope.launch { - try { - updateInstallTimestamp(downloadedArtifact) - } catch (_: Exception) { - // History is best-effort; never block installation. - } - intentManager.installApk(file) + fun installArtifact(artifactUiModel: ArtifactUiModel) { + val downloadState = artifactUiModel.downloadState as? DownloadState.Downloaded ?: return + val provenance = findArtifact(artifactUiModel.uniqueKey)?.let { downloadedArtifact -> + TryBuildProvenance( + project = downloadedArtifact.push.project, + revision = downloadedArtifact.push.revision ?: return@let null, + commitMessage = downloadedArtifact.push.pushComment, + ) } + installCoordinator.install(artifactUiModel.uniqueKey, downloadState.file, provenance) } + fun cancelInstallConflict(artifactKey: String) = installCoordinator.cancelConflict(artifactKey) + + fun confirmUninstallAndRetry(artifactKey: String) = installCoordinator.confirmUninstallAndRetry(artifactKey) + + fun openInstalledApp(packageName: String) = installCoordinator.openInstalledApp(packageName) + private fun updateArtifactDownloadState( taskIdToUpdate: String, artifactNameToUpdate: String, @@ -459,6 +921,14 @@ class ProfileViewModel( job.updateArtifact(artifactNameToUpdate, newState) } }, + unsignedJobs = + unsignedJobs.map { job: JobDetailsUiModel -> + if (job.taskId != taskId) { + job + } else { + job.updateArtifact(artifactNameToUpdate, newState) + } + }, ) private fun JobDetailsUiModel.updateArtifact( @@ -475,19 +945,76 @@ class ProfileViewModel( }, ) - private fun findDownloadedArtifact(file: File): DownloadedArtifact? = - _pushes.value.firstNotNullOfOrNull { push -> - push.jobs.firstNotNullOfOrNull { job -> - job.artifacts.firstOrNull { artifact -> - val downloadState = artifact.downloadState - downloadState is DownloadState.Downloaded && downloadState.file.absolutePath == file.absolutePath - }?.let { artifact -> DownloadedArtifact(push, job, artifact) } + private fun syncLoadedStateDownloadStates() { + val persistedDownloads = downloadStates.value + _pushes.value = _pushes.value.map { push -> + push.copy( + jobs = push.jobs.map { job -> + job.copy( + artifacts = job.artifacts.map { artifact -> + artifact.copy( + downloadState = resolveDownloadState( + artifactName = artifact.name.substringAfterLast('/'), + taskId = artifact.taskId, + uniqueKey = artifact.uniqueKey, + ), + ) + }, + ) + }, + unsignedJobs = push.unsignedJobs.map { job -> + job.copy( + artifacts = job.artifacts.map { artifact -> + artifact.copy( + downloadState = resolveDownloadState( + artifactName = artifact.name.substringAfterLast('/'), + taskId = artifact.taskId, + uniqueKey = artifact.uniqueKey, + ), + ) + }, + ) + }, + ) + } + } + + private fun resolveDownloadState( + artifactName: String, + taskId: String, + uniqueKey: String, + ): DownloadState { + val downloadedFile = getDownloadedFile(artifactName, taskId) + return downloadStates.value[uniqueKey]?.toDownloadState(downloadedFile) + ?: if (downloadedFile != null) { + DownloadState.Downloaded(downloadedFile) + } else { + DownloadState.NotDownloaded } + } + + private fun PersistedDownloadState.toDownloadState(file: File?): DownloadState = + when (status) { + DownloadStatus.QUEUED, + DownloadStatus.RUNNING, + -> DownloadState.InProgress( + progress = progress ?: 0f, + isIndeterminate = totalBytes <= 0L, + ) + + DownloadStatus.SUCCEEDED -> if (file != null && file.exists()) { + DownloadState.Downloaded(file) + } else { + DownloadState.NotDownloaded + } + + DownloadStatus.FAILED -> DownloadState.DownloadFailed(errorMessage) + DownloadStatus.CANCELED -> DownloadState.NotDownloaded } private fun findArtifact(uniqueKey: String): DownloadedArtifact? = _pushes.value.firstNotNullOfOrNull { push -> - push.jobs.firstNotNullOfOrNull { job -> + (push.jobs + push.unsignedJobs).firstNotNullOfOrNull { job -> job.artifacts.firstOrNull { artifact -> artifact.uniqueKey == uniqueKey }?.let { artifact -> DownloadedArtifact(push, job, artifact) } @@ -525,7 +1052,7 @@ class ProfileViewModel( ): TreeherderInstallHistoryEntry { val artifactFileName = artifact.name.substringAfterLast('/') return TreeherderInstallHistoryEntry( - project = "try", + project = push.project, revision = push.revision ?: "unknown_revision", commitMessage = push.pushComment, author = push.author, diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/PushTimeFormatter.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/PushTimeFormatter.kt new file mode 100644 index 0000000..40f9dab --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/PushTimeFormatter.kt @@ -0,0 +1,25 @@ +package org.mozilla.tryfox.ui.screens + +import java.time.Instant +import java.time.ZoneId +import java.time.format.DateTimeFormatter +import java.time.temporal.ChronoUnit +import java.util.Locale + +internal fun formatRelativePushTime( + pushTimestampSeconds: Long, + nowMillis: Long = System.currentTimeMillis(), + zoneId: ZoneId = ZoneId.systemDefault(), + locale: Locale = Locale.getDefault(), +): String { + val pushTime = Instant.ofEpochSecond(pushTimestampSeconds).atZone(zoneId) + val today = Instant.ofEpochMilli(nowMillis).atZone(zoneId).toLocalDate() + val date = pushTime.toLocalDate() + val time = pushTime.toLocalTime().truncatedTo(ChronoUnit.MINUTES) + .format(DateTimeFormatter.ofPattern("HH:mm", locale)) + return when { + date == today -> "Today at $time" + date == today.minusDays(1) -> "Yesterday at $time" + else -> "${date.format(DateTimeFormatter.ofPattern("MMM d", locale))} at $time" + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt index 638bca1..08f97a5 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/ReceiveFromDesktopScreen.kt @@ -12,12 +12,13 @@ import androidx.compose.foundation.Image import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons import androidx.compose.material.icons.automirrored.filled.ArrowBack import androidx.compose.material3.Button @@ -57,6 +58,7 @@ import org.mozilla.tryfox.lan.TryFoxLanReceiveService fun ReceiveFromDesktopScreen( onNavigateUp: () -> Unit, onNavigateToMessageHistory: () -> Unit, + onNavigateToTreeherderRevision: (project: String, revision: String) -> Unit, receiveFromDesktopViewModel: ReceiveFromDesktopViewModel, startReceiverOnEnter: Boolean = false, onStartReceiverOnEnterConsumed: () -> Unit = {}, @@ -159,167 +161,189 @@ fun ReceiveFromDesktopScreen( modifier = Modifier .fillMaxSize() .padding(innerPadding) - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), + .padding(horizontal = 16.dp), ) { - if (permissionState != NotificationPermissionState.GRANTED) { + Column( + modifier = Modifier + .weight(1f) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(top = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (permissionState != NotificationPermissionState.GRANTED) { + Card( + modifier = Modifier.clickable { requestOrOpenSettings() }, + shape = RoundedCornerShape(8.dp), + elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), + colors = CardDefaults.cardColors( + containerColor = MaterialTheme.colorScheme.secondaryContainer, + contentColor = MaterialTheme.colorScheme.onSecondaryContainer, + ), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text( + text = stringResource( + id = if (permissionState == NotificationPermissionState.BLOCKED) { + R.string.lan_receive_permission_card_blocked_title + } else { + R.string.lan_receive_permission_card_title + }, + ), + style = MaterialTheme.typography.titleMedium, + ) + Text( + text = stringResource( + id = if (permissionState == NotificationPermissionState.BLOCKED) { + R.string.lan_receive_permission_card_blocked_body + } else { + R.string.lan_receive_permission_card_body + }, + ), + style = MaterialTheme.typography.bodyMedium, + ) + } + } + } + Card( - modifier = Modifier.clickable { requestOrOpenSettings() }, shape = RoundedCornerShape(8.dp), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - colors = CardDefaults.cardColors( - containerColor = MaterialTheme.colorScheme.secondaryContainer, - contentColor = MaterialTheme.colorScheme.onSecondaryContainer, - ), ) { Column( modifier = Modifier .fillMaxWidth() .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(4.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), ) { Text( - text = stringResource( - id = if (permissionState == NotificationPermissionState.BLOCKED) { - R.string.lan_receive_permission_card_blocked_title - } else { - R.string.lan_receive_permission_card_title - }, - ), + text = stringResource(id = R.string.lan_receive_state_label, state.status.toDisplayName()), style = MaterialTheme.typography.titleMedium, ) - Text( - text = stringResource( - id = if (permissionState == NotificationPermissionState.BLOCKED) { - R.string.lan_receive_permission_card_blocked_body - } else { - R.string.lan_receive_permission_card_body - }, - ), - style = MaterialTheme.typography.bodyMedium, - ) + if (state.endpoint != null) { + Text( + text = stringResource(id = R.string.lan_receive_endpoint_label, state.endpoint!!), + style = MaterialTheme.typography.bodyMedium, + ) + } + if (state.errorMessage != null) { + Text( + text = stringResource(id = R.string.lan_receive_error_label, state.errorMessage!!), + color = MaterialTheme.colorScheme.error, + style = MaterialTheme.typography.bodyMedium, + ) + } } } - } - Card( - shape = RoundedCornerShape(8.dp), - elevation = CardDefaults.cardElevation(defaultElevation = 2.dp), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - Text( - text = stringResource(id = R.string.lan_receive_state_label, state.status.toDisplayName()), - style = MaterialTheme.typography.titleMedium, - ) - if (state.endpoint != null) { - Text( - text = stringResource(id = R.string.lan_receive_endpoint_label, state.endpoint!!), - style = MaterialTheme.typography.bodyMedium, - ) + if (state.status == LanReceiveStatus.LISTENING && state.qrPayloadJson != null) { + val qrCode = remember(state.qrPayloadJson) { + qrCodeBitmap(state.qrPayloadJson!!, sizePx = 768) } - if (state.errorMessage != null) { - Text( - text = stringResource(id = R.string.lan_receive_error_label, state.errorMessage!!), - color = MaterialTheme.colorScheme.error, - style = MaterialTheme.typography.bodyMedium, - ) - } - } - } - - if (state.status == LanReceiveStatus.LISTENING && state.qrPayloadJson != null) { - val qrCode = remember(state.qrPayloadJson) { - qrCodeBitmap(state.qrPayloadJson!!, sizePx = 768) - } - Card(shape = RoundedCornerShape(8.dp)) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(id = R.string.lan_receive_qr_title), - style = MaterialTheme.typography.titleMedium, - ) - Image( - bitmap = qrCode, - contentDescription = stringResource(id = R.string.lan_receive_qr_description), - modifier = Modifier.size(240.dp), - ) + Card(shape = RoundedCornerShape(8.dp)) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = stringResource(id = R.string.lan_receive_qr_title), + style = MaterialTheme.typography.titleMedium, + ) + Image( + bitmap = qrCode, + contentDescription = stringResource(id = R.string.lan_receive_qr_description), + modifier = Modifier.size(240.dp), + ) + } } } - } - if (state.lastReceivedMessage != null) { - val lastMessage = state.lastReceivedMessage!! - Card(shape = RoundedCornerShape(8.dp)) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(16.dp), - verticalArrangement = Arrangement.spacedBy(8.dp), + state.lastReceivedMessage?.let { lastMessage -> + val project = lastMessage.repo?.takeIf { it.isNotBlank() } ?: "try" + val canOpenTreeherder = !lastMessage.revision.isNullOrBlank() + Card( + modifier = Modifier.clickable(enabled = canOpenTreeherder) { + onNavigateToTreeherderRevision( + project, + lastMessage.revision!!, + ) + }, + shape = RoundedCornerShape(8.dp), ) { - Text( - text = stringResource(id = R.string.lan_receive_last_message_title), - style = MaterialTheme.typography.titleMedium, - ) - Text( - text = if (lastMessage.accepted) { - stringResource(id = R.string.lan_receive_last_message_accepted) - } else { - stringResource( - id = R.string.lan_receive_last_message_rejected, - lastMessage.error ?: stringResource(id = R.string.common_unknown_error), - ) - }, - style = MaterialTheme.typography.bodyMedium, - ) - lastMessage.revision?.let { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { Text( - text = stringResource(id = R.string.lan_receive_last_message_revision, it), - style = MaterialTheme.typography.bodySmall, + text = stringResource(id = R.string.lan_receive_last_message_title), + style = MaterialTheme.typography.titleMedium, ) - } - lastMessage.author?.let { Text( - text = stringResource(id = R.string.lan_receive_last_message_author, it), - style = MaterialTheme.typography.bodySmall, + text = if (lastMessage.accepted) { + stringResource(id = R.string.lan_receive_last_message_accepted) + } else { + stringResource( + id = R.string.lan_receive_last_message_rejected, + lastMessage.error ?: stringResource(id = R.string.common_unknown_error), + ) + }, + style = MaterialTheme.typography.bodyMedium, ) + lastMessage.revision?.let { + Text( + text = stringResource(id = R.string.lan_receive_last_message_revision, it), + style = MaterialTheme.typography.bodySmall, + ) + } + lastMessage.author?.let { + Text( + text = stringResource(id = R.string.lan_receive_last_message_author, it), + style = MaterialTheme.typography.bodySmall, + ) + } } } } } - Spacer(modifier = Modifier.weight(1f)) - - if (state.status == LanReceiveStatus.LISTENING || state.status == LanReceiveStatus.STARTING) { - Button( - onClick = { context.startService(TryFoxLanReceiveService.stopIntent(context)) }, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(id = R.string.lan_receive_stop_button)) + Column( + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + if (state.status == LanReceiveStatus.LISTENING || state.status == LanReceiveStatus.STARTING) { + Button( + onClick = { context.startService(TryFoxLanReceiveService.stopIntent(context)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(id = R.string.lan_receive_stop_button)) + } + } else { + Button( + onClick = requestStartReceiver, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(id = R.string.lan_receive_start_button)) + } } - } else { - Button( - onClick = requestStartReceiver, + + OutlinedButton( + onClick = onNavigateToMessageHistory, modifier = Modifier.fillMaxWidth(), ) { - Text(stringResource(id = R.string.lan_receive_start_button)) + Text(stringResource(id = R.string.lan_receive_message_history_button)) } } - - OutlinedButton( - onClick = onNavigateToMessageHistory, - modifier = Modifier.fillMaxWidth(), - ) { - Text(stringResource(id = R.string.lan_receive_message_history_button)) - } } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchHistoryViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchHistoryViewModel.kt new file mode 100644 index 0000000..1e2aa2b --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchHistoryViewModel.kt @@ -0,0 +1,26 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.repositories.UserDataRepository + +class SearchHistoryViewModel( + private val userDataRepository: UserDataRepository, +) : ViewModel() { + val searchHistory: StateFlow> = userDataRepository.searchHistoryFlow.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = emptyList(), + ) + + fun recordSuccessfulSearch(project: String, query: String) { + viewModelScope.launch { + runCatching { userDataRepository.recordSearch(project, query) } + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt new file mode 100644 index 0000000..8f99a40 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchQuery.kt @@ -0,0 +1,30 @@ +package org.mozilla.tryfox.ui.screens + +/** The two Treeherder request types accepted by the unified build search. */ +sealed interface SearchQuery { + val value: String + + data class Email(override val value: String) : SearchQuery + data class Revision(override val value: String) : SearchQuery + data object RecentPushes : SearchQuery { + override val value = "" + } +} + +/** + * Keeps query validation independent of Compose and networking. An @ is deliberately + * treated strictly: it must form an email rather than accidentally becoming a revision. + */ +object SearchQueryClassifier { + private val emailPattern = Regex("^[^@\\s]+@[^@\\s]+\\.[^@\\s]+$") + + fun classify(input: String): Result { + val value = input.trim() + return when { + value.isBlank() -> Result.success(SearchQuery.RecentPushes) + '@' !in value -> Result.success(SearchQuery.Revision(value)) + emailPattern.matches(value) -> Result.success(SearchQuery.Email(value)) + else -> Result.failure(IllegalArgumentException("Invalid search query.")) + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt new file mode 100644 index 0000000..f93ff95 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt @@ -0,0 +1,226 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.KeyboardArrowDown +import androidx.compose.material.icons.filled.KeyboardArrowUp +import androidx.compose.material.icons.filled.Warning +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.Hyphens +import androidx.compose.ui.unit.dp +import org.mozilla.tryfox.R +import org.mozilla.tryfox.install.InstallState +import org.mozilla.tryfox.ui.composables.AppIcon +import org.mozilla.tryfox.ui.composables.DownloadButton +import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment +import org.mozilla.tryfox.ui.models.ArtifactUiModel +import org.mozilla.tryfox.ui.models.JobDetailsUiModel +import org.mozilla.tryfox.ui.models.PushUiModel +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_NIGHTLY +import org.mozilla.tryfox.util.FENIX_RELEASE +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_NIGHTLY +import org.mozilla.tryfox.util.FOCUS_RELEASE +import org.mozilla.tryfox.util.withoutTrailingReviewerDirective +import java.util.Locale + +@Suppress("LongParameterList") +@Composable +internal fun PushResultCard( + push: PushUiModel, + onDownloadClick: (ArtifactUiModel) -> Unit, + onInstallClick: (ArtifactUiModel) -> Unit, + onOpenClick: (String) -> Unit, + installStates: Map, + activeInstallKey: String?, + testTag: String, +) { + val commitTitle = remember(push.pushComment) { + push.pushComment.withoutTrailingReviewerDirective().lineSequence().firstOrNull().orEmpty().trim() + .ifBlank { "Revision ${push.revision?.take(12).orEmpty()}" } + } + Card(modifier = Modifier.fillMaxWidth().testTag(testTag), elevation = CardDefaults.cardElevation(defaultElevation = 2.dp)) { + Column(modifier = Modifier.padding(16.dp)) { + Text(rememberLinkedPushComment(commitTitle), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + if (push.pushTimestamp > 0L || push.author.isNotBlank()) { + Text(listOfNotNull(formatRelativePushTime(push.pushTimestamp).takeIf { push.pushTimestamp > 0L }, push.author.takeIf(String::isNotBlank)).joinToString(" · "), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(top = 8.dp)) + } + HorizontalDivider(modifier = Modifier.padding(top = 14.dp)) + push.jobs.forEachIndexed { index, job -> + if (index > 0) HorizontalDivider() + CompactApkRow(job, onDownloadClick, onInstallClick, onOpenClick, installStates, activeInstallKey) + } + if (push.unsignedJobs.isNotEmpty()) { + UnsignedApksSection(push, onDownloadClick, onInstallClick, onOpenClick, installStates, activeInstallKey) + } + } + } +} + +@Composable +private fun UnsignedApksSection( + push: PushUiModel, + onDownloadClick: (ArtifactUiModel) -> Unit, + onInstallClick: (ArtifactUiModel) -> Unit, + onOpenClick: (String) -> Unit, + installStates: Map, + activeInstallKey: String?, +) { + var expanded by rememberSaveable(push.revision) { mutableStateOf(false) } + val stateDescription = stringResource( + if (expanded) R.string.search_result_unsigned_apks_expanded else R.string.search_result_unsigned_apks_collapsed, + ) + + HorizontalDivider() + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { expanded = !expanded } + .semantics { this.stateDescription = stateDescription } + .testTag("unsigned_apks_toggle_${push.revision}") + .padding(vertical = 14.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Text( + text = pluralStringResource( + R.plurals.search_result_unsigned_apks, + push.unsignedJobs.size, + push.unsignedJobs.size, + ), + style = MaterialTheme.typography.bodyMedium, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.weight(1f), + ) + Icon(if (expanded) Icons.Filled.KeyboardArrowUp else Icons.Filled.KeyboardArrowDown, contentDescription = null) + } + AnimatedVisibility( + visible = expanded, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Column(modifier = Modifier.padding(start = 16.dp)) { + Row( + modifier = Modifier.padding(top = 12.dp, end = 8.dp, bottom = 12.dp), + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = Icons.Filled.Warning, + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + ) + Text( + text = stringResource(R.string.search_result_unsigned_apks_warning), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 8.dp), + ) + } + push.unsignedJobs.forEach { job -> + HorizontalDivider() + CompactApkRow( + job = job, + onDownloadClick = onDownloadClick, + onInstallClick = onInstallClick, + onOpenClick = onOpenClick, + installStates = installStates, + activeInstallKey = activeInstallKey, + modifier = Modifier.testTag("unsigned_apk_row_${job.taskId}"), + ) + } + } + } +} + +@Composable +private fun CompactApkRow( + job: JobDetailsUiModel, + onDownloadClick: (ArtifactUiModel) -> Unit, + onInstallClick: (ArtifactUiModel) -> Unit, + onOpenClick: (String) -> Unit, + installStates: Map, + activeInstallKey: String?, + modifier: Modifier = Modifier, +) { + val apk = remember(job.artifacts) { job.artifacts.firstOrNull { it.abi.isSupported } } + val appIconName = remember(job.jobName, job.appName) { appIconNameForJob(job.jobName, job.appName) } + val installState = apk?.let { installStates[it.uniqueKey] ?: InstallState.Idle } + Column(modifier = modifier.fillMaxWidth().padding(vertical = 12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + AppIcon(appName = appIconName, modifier = Modifier.size(34.dp), useSearchResultVariant = true) + Text(job.jobName.ifBlank { formatAppNameForDisplay(job.appName) }.let(::formatJobNameForDisplay), style = MaterialTheme.typography.bodyMedium.copy(hyphens = Hyphens.Auto), fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f)) + Spacer(Modifier.width(8.dp)) + apk?.let { artifact -> + DownloadButton( + downloadState = artifact.downloadState, + onDownloadClick = { onDownloadClick(artifact) }, + onInstallClick = { onInstallClick(artifact) }, + modifier = Modifier.width(112.dp), + inProgressText = stringResource(id = R.string.download_button_download), + installState = installState ?: InstallState.Idle, + installDisabled = activeInstallKey != null && activeInstallKey != artifact.uniqueKey, + onOpenClick = onOpenClick, + ) + } + } + (installState as? InstallState.Failed)?.let { failure -> + Text( + text = failure.message, + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.error, + modifier = Modifier.padding(start = 42.dp, top = 6.dp), + ) + } + } +} + +private fun formatAppNameForDisplay(appName: String): String = when (appName.lowercase(Locale.getDefault())) { + FENIX_NIGHTLY -> "Fenix Nightly"; FENIX -> "Fenix"; FOCUS -> "Focus Nightly"; FOCUS_RELEASE -> "Focus Release" + else -> appName.replaceFirstChar { if (it.isLowerCase()) it.titlecase(Locale.getDefault()) else it.toString() } +} + +private val signingApkJobNamePattern = Regex("signing-apk-(fenix|focus)-(debug|nightly|beta|release)(-(firebase|simulation))?", RegexOption.IGNORE_CASE) +internal fun formatJobNameForDisplay(jobName: String): String { + val match = signingApkJobNamePattern.matchEntire(jobName.trim()) ?: return jobName + val appName = when (match.groupValues[1].lowercase(Locale.ROOT)) { FENIX -> "Fenix"; FOCUS -> "Focus"; else -> return jobName } + val suffix = when (match.groupValues[4].lowercase(Locale.ROOT)) { "firebase" -> " (firebase)"; "simulation" -> " (perftests)"; else -> "" } + return "$appName ${match.groupValues[2].lowercase(Locale.ROOT)}$suffix" +} +internal fun appIconNameForJob(jobName: String, fallbackAppName: String): String = when { + "focus-debug" in jobName.lowercase(Locale.ROOT) -> FOCUS; "focus-nightly" in jobName.lowercase(Locale.ROOT) -> FOCUS_NIGHTLY; "focus-beta" in jobName.lowercase(Locale.ROOT) -> FOCUS_BETA; "focus" in jobName.lowercase(Locale.ROOT) -> FOCUS + "fenix-debug" in jobName.lowercase(Locale.ROOT) -> FENIX; "fenix-nightly" in jobName.lowercase(Locale.ROOT) -> FENIX_NIGHTLY; "fenix-release" in jobName.lowercase(Locale.ROOT) -> FENIX_RELEASE; "fenix-beta" in jobName.lowercase(Locale.ROOT) -> FENIX_BETA + else -> fallbackAppName +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt new file mode 100644 index 0000000..8ceefdd --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt @@ -0,0 +1,569 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +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 +import androidx.compose.material.icons.filled.History +import androidx.compose.material.icons.filled.Search +import androidx.compose.material3.Button +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.PlainTooltip +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TooltipBox +import androidx.compose.material3.TooltipDefaults +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.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.onFocusChanged +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 +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import org.mozilla.tryfox.R +import org.mozilla.tryfox.data.SearchHistory +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.install.InstallState +import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.ui.composables.BinButton +import org.mozilla.tryfox.ui.composables.ProjectSelector + +// Project name mappings +private val projectDisplayToActualMap = mapOf( + "try" to "try", + "central" to "mozilla-central", + "beta" to "mozilla-beta", + "release" to "mozilla-release", + "autoland" to "autoland", +) + +internal const val TREEHERDER_LOADING_STATE_TAG = "treeherder_loading_state" +internal const val TREEHERDER_LOADING_MORE_TAG = "treeherder_loading_more" +internal const val TREEHERDER_LOAD_MORE_ERROR_TAG = "treeherder_load_more_error" +internal const val TREEHERDER_SEARCH_HISTORY_TAG = "treeherder_search_history" + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchScreen( + searchViewModel: SearchViewModel, + deepLinkProject: String?, + deepLinkQuery: String?, + onNavigateUp: () -> Unit, + searchHistory: List = emptyList(), +) { + val cacheState by searchViewModel.cacheState.collectAsState() + val query by searchViewModel.query.collectAsState() + val selectedProject by searchViewModel.selectedProject.collectAsState() + val isLoading by searchViewModel.isLoading.collectAsState() + val isLoadingMore by searchViewModel.isLoadingMore.collectAsState() + val canLoadMore by searchViewModel.canLoadMore.collectAsState() + val errorMessage by searchViewModel.errorMessage.collectAsState() + val loadMoreError by searchViewModel.loadMoreError.collectAsState() + val warningMessage by searchViewModel.warningMessage.collectAsState() + val hasReachedExpiredJobs by searchViewModel.hasReachedExpiredJobs.collectAsState() + val pushes by searchViewModel.pushes.collectAsState() + val installStates by searchViewModel.installStates.collectAsState() + val activeInstallKey = installStates.entries.firstOrNull { (_, state) -> + state is InstallState.Installing || state is InstallState.Uninstalling || state is InstallState.Conflict + }?.key + val isDownloading = pushes.any { push -> + (push.jobs + push.unsignedJobs).any { job -> + job.artifacts.any { it.downloadState is org.mozilla.tryfox.data.DownloadState.InProgress } + } + } + var hasSubmittedSearch by rememberSaveable(deepLinkQuery) { mutableStateOf(deepLinkQuery != null) } + var displayedQuery by rememberSaveable(deepLinkQuery) { mutableStateOf(deepLinkQuery.orEmpty()) } + var isSearchFieldFocused by remember { mutableStateOf(false) } + + val isEditingDisplayedSearch = hasSubmittedSearch && + isSearchFieldFocused && + query != displayedQuery + val showSearchHistory = !hasSubmittedSearch || isEditingDisplayedSearch + val showCurrentSearch = !isEditingDisplayedSearch + fun submitSearch(queryToSubmit: String) { + hasSubmittedSearch = true + displayedQuery = queryToSubmit + searchViewModel.submitSearch() + } + + LaunchedEffect(deepLinkProject, deepLinkQuery) { + deepLinkQuery?.let { searchViewModel.setQueryFromDeepLinkAndSearch(deepLinkProject, it) } + } + + val binButtonEnabled = !isDownloading && activeInstallKey == null && cacheState == CacheManagementState.IdleNonEmpty + + Scaffold( + modifier = Modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(id = R.string.profile_screen_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.common_back_button_description)) + } + }, + actions = { + val tooltipState = rememberTooltipState() + TooltipBox( + positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), + tooltip = { + PlainTooltip { + Text(stringResource(id = R.string.bin_button_tooltip_clear_downloaded_apks)) + } + }, + state = tooltipState, + ) { + BinButton( + cacheState = cacheState, + onConfirm = { searchViewModel.clearAppCache() }, + enabled = binButtonEnabled, + ) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, // Added for consistency + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .fillMaxSize() + .padding(innerPadding), + ) { + // Keep the controls available while the suggestions below scroll. + Column(modifier = Modifier.padding(16.dp)) { + SearchSection( + selectedProject = selectedProject, + onProjectSelected = searchViewModel::updateSelectedProject, + revision = query, + onRevisionChange = { + // A text edit is unambiguously an editing interaction, including + // the trailing clear action whose focus callback can be delayed. + isSearchFieldFocused = true + searchViewModel.updateQuery(it) + }, + onSearchClick = { submitSearch(query) }, + isLoading = isLoading, + showSearchHistory = false, + onSearchFieldFocusChanged = { isSearchFieldFocused = it }, + ) + } + + LazyColumn( + modifier = Modifier.weight(1f), + contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + if (showSearchHistory) { + item { + SearchHistoryPanel( + entries = SearchHistory.displayOrder(searchHistory) + .filter { it.query.contains(query.trim(), ignoreCase = true) }, + visible = true, + onEntryClick = { entry -> + searchViewModel.updateSelectedProject(entry.project) + searchViewModel.updateQuery(entry.query) + submitSearch(entry.query) + }, + ) + } + } + + errorMessage?.let { + // TODO: Consider creating a specific string resource for \"Download failed\" if it's a common prefix for user-facing errors. + if (pushes.isEmpty() || !it.startsWith("Download failed")) { + item { + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + ErrorState(errorMessage = it) + } + } + } + } + + warningMessage?.takeUnless { hasReachedExpiredJobs }?.let { + item { + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + WarningState(warningMessage = it) + } + } + } + + if (isLoading) { + item { + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + LoadingState(candidateCount = 0) + } + } + } else if (pushes.isNotEmpty()) { + itemsIndexed( + items = pushes, + key = { _, push -> "search_push_${push.revision ?: push.pushTimestamp}" }, + ) { _, push -> + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PushResultCard( + push = push, + onDownloadClick = searchViewModel::downloadArtifact, + onInstallClick = searchViewModel::installArtifact, + onOpenClick = searchViewModel::openInstalledApp, + installStates = installStates, + activeInstallKey = activeInstallKey, + testTag = "search_push_${push.revision}", + ) + } + } + + if (isLoadingMore) { + item { LoadingMoreFooter() } + } + + loadMoreError?.let { message -> + item { + LoadMoreErrorFooter( + errorMessage = message, + onRetry = searchViewModel::retryLoadMorePushes, + ) + } + } + } else if (canLoadMore && isLoadingMore) { + item { + LoadingMoreFooter() + } + } + + if (canLoadMore && !isLoading && !isLoadingMore && loadMoreError == null && showCurrentSearch) { + item { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Button( + onClick = searchViewModel::loadMorePushes, + modifier = Modifier.padding(vertical = 4.dp), + ) { + Text("Load more") + } + } + } + } + + if (hasReachedExpiredJobs && showCurrentSearch) { + warningMessage?.let { message -> + item { WarningState(warningMessage = message) } + } + } + } + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SearchSection( + selectedProject: String, + onProjectSelected: (String) -> Unit, + revision: String, + onRevisionChange: (String) -> Unit, + onSearchClick: () -> Unit, + isLoading: Boolean, + showSearchHistory: Boolean = true, + onSearchFieldFocusChanged: (Boolean) -> Unit = {}, + searchHistory: List = emptyList(), + onHistoryItemSelected: (SearchHistoryEntry) -> Unit = {}, +) { + val projectDisplayOptions = projectDisplayToActualMap.keys.toList() + Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + ProjectSelector( + projects = projectDisplayOptions, + selectedProject = projectDisplayToActualMap.entries.first { it.value == selectedProject }.key, + projectLabel = { it }, + onProjectSelected = { displayProject -> onProjectSelected(projectDisplayToActualMap.getValue(displayProject)) }, + enabled = !isLoading, + modifier = Modifier.height(52.dp), + ) + SearchInputRow( + query = revision, + onQueryChange = onRevisionChange, + onSearchClick = onSearchClick, + isLoading = isLoading, + onFocusChanged = onSearchFieldFocusChanged, + ) + SearchHistoryPanel( + entries = searchHistory.filter { it.query.contains(revision.trim(), ignoreCase = true) }, + visible = showSearchHistory, + onEntryClick = onHistoryItemSelected, + ) + } +} + +@Composable +internal fun SearchHistoryPanel( + entries: List, + visible: Boolean, + onEntryClick: (SearchHistoryEntry) -> Unit, +) { + AnimatedVisibility( + visible = visible && entries.isNotEmpty(), + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + Card( + modifier = Modifier + .fillMaxWidth() + .testTag(TREEHERDER_SEARCH_HISTORY_TAG), + shape = RoundedCornerShape(20.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Column { + Text( + text = stringResource(R.string.search_history_title), + style = MaterialTheme.typography.labelLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp, vertical = 12.dp), + ) + entries.forEachIndexed { index, entry -> + if (index > 0) { + HorizontalDivider( + modifier = Modifier.padding(horizontal = 16.dp), + color = MaterialTheme.colorScheme.outlineVariant, + ) + } + Row( + modifier = Modifier + .fillMaxWidth() + .heightIn(min = 48.dp) + .clip(RoundedCornerShape(12.dp)) + .clickable { onEntryClick(entry) } + .testTag("treeherder_search_history_$index") + .padding(horizontal = 16.dp, vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + Icon( + imageVector = Icons.Default.History, + contentDescription = null, + tint = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Text( + text = entry.query, + style = MaterialTheme.typography.bodyLarge, + modifier = Modifier.weight(1f), + ) + Text( + text = projectDisplayToActualMap.entries.firstOrNull { it.value == entry.project }?.key ?: entry.project, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.onSecondaryContainer, + modifier = Modifier + .clip(RoundedCornerShape(10.dp)) + .background(MaterialTheme.colorScheme.secondaryContainer) + .padding(horizontal = 8.dp, vertical = 4.dp), + ) + } + } + } + } + } +} + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +internal fun SearchInputRow( + query: String, + onQueryChange: (String) -> Unit, + onSearchClick: () -> Unit, + isLoading: Boolean, + onFocusChanged: (Boolean) -> Unit = {}, +) { + val keyboardController = LocalSoftwareKeyboardController.current + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically) { + androidx.compose.material3.OutlinedTextField( + value = query, + onValueChange = onQueryChange, + placeholder = { Text(stringResource(R.string.profile_screen_user_email_label), maxLines = 1) }, + modifier = Modifier.weight(1f).heightIn(min = 56.dp).onFocusChanged { onFocusChanged(it.isFocused) }.testTag("search_query_input"), + singleLine = true, + shape = RoundedCornerShape(20.dp), + leadingIcon = { Icon(Icons.Default.Search, contentDescription = null) }, + trailingIcon = { if (query.isNotEmpty()) IconButton(onClick = { onQueryChange("") }) { Icon(Icons.Filled.Close, contentDescription = stringResource(R.string.profile_screen_clear_email_description)) } }, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearchClick(); keyboardController?.hide() }), + ) + Button(onClick = { onSearchClick(); keyboardController?.hide() }, enabled = !isLoading, modifier = Modifier.size(52.dp), shape = RoundedCornerShape(24.dp), colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), contentPadding = PaddingValues(0.dp)) { + if (isLoading) CircularProgressIndicator(modifier = Modifier.size(24.dp), color = MaterialTheme.colorScheme.onPrimary) + else Icon(Icons.Default.Search, contentDescription = stringResource(R.string.profile_screen_search_button_description)) + } + } +} + +@Composable +fun LoadingState(candidateCount: Int) { + Card( + elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), + modifier = Modifier + .fillMaxWidth() + .testTag(TREEHERDER_LOADING_STATE_TAG), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp, vertical = 24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(6.dp), + ) { + Text( + text = stringResource(R.string.search_loading_apk_builds), + style = MaterialTheme.typography.headlineSmall, + fontWeight = FontWeight.Bold, + ) + Text( + text = if (candidateCount > 0) { + "Inspecting $candidateCount candidate job${if (candidateCount == 1) "" else "s"} and resolving APK artifacts." + } else { + stringResource(id = R.string.treeherder_apks_loading_message) + }, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } + LinearProgressIndicator( + modifier = Modifier.fillMaxWidth(), + ) + } + } +} + +@Composable +fun ErrorState(errorMessage: String) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = errorMessage, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +fun WarningState(warningMessage: String) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer), + modifier = Modifier.fillMaxWidth(), + ) { + Text( + text = warningMessage, + modifier = Modifier.padding(16.dp), + color = MaterialTheme.colorScheme.onTertiaryContainer, + style = MaterialTheme.typography.bodyMedium, + ) + } +} + +@Composable +private fun LoadingMoreFooter() { + Row( + modifier = Modifier.fillMaxWidth().padding(16.dp), + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.size(24.dp).testTag(TREEHERDER_LOADING_MORE_TAG)) + } +} + +@Composable +private fun LoadMoreErrorFooter( + errorMessage: String, + onRetry: () -> Unit, +) { + Card( + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), + modifier = Modifier.fillMaxWidth().testTag(TREEHERDER_LOAD_MORE_ERROR_TAG), + ) { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Text( + text = errorMessage, + color = MaterialTheme.colorScheme.onErrorContainer, + style = MaterialTheme.typography.bodyMedium, + ) + Button(onClick = onRetry, modifier = Modifier.align(Alignment.End)) { + Text("Retry") + } + } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt new file mode 100644 index 0000000..dbf64e6 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt @@ -0,0 +1,231 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.ListItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Scaffold +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarDefaults +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.semantics.selected +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import org.mozilla.tryfox.R +import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.HomeScreenLayout + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun SettingsScreen( + onNavigateUp: () -> Unit, + settingsViewModel: SettingsViewModel = viewModel(), + modifier: Modifier = Modifier, +) { + val uiState by settingsViewModel.uiState.collectAsState() + var showClearConfirmation by remember { mutableStateOf(false) } + + if (showClearConfirmation) { + AlertDialog( + onDismissRequest = { showClearConfirmation = false }, + title = { Text(stringResource(R.string.settings_clear_cache_dialog_title)) }, + text = { Text(stringResource(R.string.settings_clear_cache_dialog_message)) }, + confirmButton = { + Button(onClick = { + settingsViewModel.clearCache() + showClearConfirmation = false + }) { Text(stringResource(R.string.settings_clear_cache_confirm)) } + }, + dismissButton = { + OutlinedButton(onClick = { showClearConfirmation = false }) { + Text(stringResource(R.string.settings_clear_cache_cancel)) + } + }, + ) + } + + Scaffold( + modifier = modifier.fillMaxSize(), + topBar = { + TopAppBar( + title = { Text(stringResource(R.string.settings_screen_title)) }, + navigationIcon = { + IconButton(onClick = onNavigateUp) { + Icon(Icons.AutoMirrored.Filled.ArrowBack, stringResource(R.string.common_back_button_description)) + } + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.primaryContainer, + titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, + ), + ) + }, + ) { innerPadding -> + Column( + modifier = Modifier + .padding(innerPadding) + .padding(horizontal = 16.dp, vertical = 24.dp) + .verticalScroll(rememberScrollState()), + verticalArrangement = Arrangement.spacedBy(20.dp), + ) { + CacheSettingsCard( + uiState = uiState, + onClearCache = { showClearConfirmation = true }, + ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + HomeLayoutSettingsCard( + selectedLayout = uiState.homeScreenLayout, + onLayoutSelected = settingsViewModel::selectHomeScreenLayout, + ) + } + } +} + +@Composable +private fun CacheSettingsCard(uiState: SettingsUiState, onClearCache: () -> Unit) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsSectionTitle(R.string.settings_cache_section_title) + PreferenceGroup { + ListItem( + headlineContent = { Text(stringResource(R.string.settings_cache_size_label)) }, + supportingContent = { Text(stringResource(R.string.settings_cache_description)) }, + trailingContent = { + Text( + text = formatCacheSize(uiState.cacheSizeBytes), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.primary, + ) + }, + ) + } + if (uiState.cacheState == CacheManagementState.Clearing) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically, + ) { + CircularProgressIndicator(modifier = Modifier.padding(end = 12.dp)) + Text(stringResource(R.string.settings_cache_clearing)) + } + } else { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + Button(onClick = onClearCache, enabled = uiState.canClearCache) { + Text(stringResource(R.string.settings_clear_cache_button)) + } + } + } + } +} + +@Composable +private fun HomeLayoutSettingsCard( + selectedLayout: HomeScreenLayout, + onLayoutSelected: (HomeScreenLayout) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsSectionTitle(R.string.settings_home_layout_section_title) + Text( + text = stringResource(R.string.settings_home_layout_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + PreferenceGroup { + LayoutOption( + layout = HomeScreenLayout.OneCardPerApp, + selectedLayout = selectedLayout, + label = stringResource(R.string.settings_layout_one_card_per_app), + onSelected = onLayoutSelected, + ) + HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + LayoutOption( + layout = HomeScreenLayout.OneCardPerFlavor, + selectedLayout = selectedLayout, + label = stringResource(R.string.settings_layout_one_card_per_flavor), + onSelected = onLayoutSelected, + ) + } + } +} + +@Composable +private fun PreferenceGroup(content: @Composable ColumnScope.() -> Unit) { + Card( + modifier = Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow), + ) { + Column( + content = content, + ) + } +} + +@Composable +private fun SettingsSectionTitle(stringId: Int) { + Text( + stringResource(stringId), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier.padding(start = 16.dp), + ) +} + +@Composable +private fun LayoutOption( + layout: HomeScreenLayout, + selectedLayout: HomeScreenLayout, + label: String, + onSelected: (HomeScreenLayout) -> Unit, +) { + val selected = layout == selectedLayout + ListItem( + headlineContent = { Text(label) }, + trailingContent = { RadioButton(selected = selected, onClick = null) }, + modifier = Modifier + .semantics { this.selected = selected } + .clickable(role = Role.RadioButton) { onSelected(layout) }, + ) +} + +internal fun formatCacheSize(bytes: Long): String = when { + bytes < 1024L -> "$bytes B" + bytes < 1024L * 1024L -> "%.1f KB".format(bytes / 1024.0) + bytes < 1024L * 1024L * 1024L -> "%.1f MB".format(bytes / (1024.0 * 1024.0)) + else -> "%.1f GB".format(bytes / (1024.0 * 1024.0 * 1024.0)) +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt new file mode 100644 index 0000000..bc42da2 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt @@ -0,0 +1,57 @@ +package org.mozilla.tryfox.ui.screens + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.launch +import org.mozilla.tryfox.data.managers.CacheManager +import org.mozilla.tryfox.data.repositories.UserDataRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.HomeScreenLayout + +data class SettingsUiState( + val cacheState: CacheManagementState = CacheManagementState.IdleEmpty, + val cacheSizeBytes: Long = 0L, + val hasActiveDownloads: Boolean = false, + val homeScreenLayout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, +) { + val canClearCache: Boolean + get() = cacheState == CacheManagementState.IdleNonEmpty && !hasActiveDownloads +} + +class SettingsViewModel( + private val cacheManager: CacheManager, + downloadCoordinator: ApkDownloadCoordinator, + private val userDataRepository: UserDataRepository, +) : ViewModel() { + val uiState: StateFlow = combine( + cacheManager.cacheState, + cacheManager.cacheSizeBytes, + downloadCoordinator.downloads, + userDataRepository.homeScreenLayoutFlow, + ) { cacheState, cacheSizeBytes, downloads, homeScreenLayout -> + SettingsUiState( + cacheState = cacheState, + cacheSizeBytes = cacheSizeBytes, + hasActiveDownloads = downloads.values.any { !it.isTerminal }, + homeScreenLayout = homeScreenLayout, + ) + }.stateIn(viewModelScope, SharingStarted.Eagerly, SettingsUiState()) + + init { + viewModelScope.launch { cacheManager.checkCacheStatus() } + } + + fun clearCache() { + if (!uiState.value.canClearCache) return + viewModelScope.launch { cacheManager.clearCache() } + } + + fun selectHomeScreenLayout(layout: HomeScreenLayout) { + viewModelScope.launch { userDataRepository.saveHomeScreenLayout(layout) } + } +} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableTryFoxCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableTryFoxCard.kt index 3122dd1..0cd66e6 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableTryFoxCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SwipeableTryFoxCard.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.layout.onGloballyPositioned import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.dp +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.composables.TryFoxCard import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.AppUiModel @@ -31,7 +32,9 @@ fun SwipeableTryFoxCard( modifier: Modifier = Modifier, tryFoxApp: AppUiModel, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (java.io.File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, onDismiss: () -> Unit, onTryFoxCardHeightChange: (Dp) -> Unit, ) { @@ -81,6 +84,8 @@ fun SwipeableTryFoxCard( app = tryFoxApp, onDownloadClick = onDownloadClick, onInstallClick = onInstallClick, + installStates = installStates, + onOpenInstalledApp = onOpenInstalledApp, ) }, ) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt deleted file mode 100644 index 13eb97b..0000000 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TreeherderApksScreen.kt +++ /dev/null @@ -1,417 +0,0 @@ -package org.mozilla.tryfox.ui.screens - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.IntrinsicSize -import androidx.compose.foundation.layout.PaddingValues -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxHeight -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -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.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.ArrowBack -import androidx.compose.material.icons.filled.Search -import androidx.compose.material3.Button -import androidx.compose.material3.ButtonDefaults -import androidx.compose.material3.Card -import androidx.compose.material3.CardDefaults -import androidx.compose.material3.CircularProgressIndicator -import androidx.compose.material3.DropdownMenuItem -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.ExposedDropdownMenuBox -import androidx.compose.material3.ExposedDropdownMenuDefaults -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.MenuAnchorType -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.OutlinedTextFieldDefaults -import androidx.compose.material3.PlainTooltip -import androidx.compose.material3.Scaffold -import androidx.compose.material3.Text -import androidx.compose.material3.TextField -import androidx.compose.material3.TooltipBox -import androidx.compose.material3.TooltipDefaults -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarDefaults -import androidx.compose.material3.rememberTooltipState -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.remember -import androidx.compose.runtime.setValue -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalLifecycleOwner -import androidx.compose.ui.platform.testTag -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.LifecycleEventObserver -import org.mozilla.tryfox.R -import org.mozilla.tryfox.TryFoxViewModel -import org.mozilla.tryfox.model.CacheManagementState -import org.mozilla.tryfox.ui.composables.AppCard -import org.mozilla.tryfox.ui.composables.BinButton -import org.mozilla.tryfox.ui.composables.PushCommentCard - -// Project name mappings -private val projectDisplayToActualMap = mapOf( - "try" to "try", - "central" to "mozilla-central", - "beta" to "mozilla-beta", - "release" to "mozilla-release", -) -private val projectActualToDisplayMap = projectDisplayToActualMap.entries.associate { (k, v) -> v to k } - -internal const val TREEHERDER_LOADING_STATE_TAG = "treeherder_loading_state" -internal const val TREEHERDER_RESULTS_HEADER_TAG = "treeherder_results_header" - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun TryFoxMainScreen( - tryFoxViewModel: TryFoxViewModel, - deepLinkProject: String?, - deepLinkRevision: String?, - onNavigateUp: () -> Unit, -) { - val cacheState by tryFoxViewModel.cacheState.collectAsState() - val isDownloading by tryFoxViewModel.isDownloadingAnyFile.collectAsState() - val lifecycleOwner = LocalLifecycleOwner.current - - LaunchedEffect(Unit) { - tryFoxViewModel.checkCacheStatus() - } - - DisposableEffect(lifecycleOwner, tryFoxViewModel) { - val observer = LifecycleEventObserver { _, event -> - if (event == Lifecycle.Event.ON_RESUME) { - tryFoxViewModel.checkCacheStatus() - } - } - lifecycleOwner.lifecycle.addObserver(observer) - onDispose { - lifecycleOwner.lifecycle.removeObserver(observer) - } - } - - LaunchedEffect(deepLinkProject, deepLinkRevision) { - if (!deepLinkRevision.isNullOrBlank()) { - val resolvedProject = deepLinkProject ?: "try" - val projectChanged = tryFoxViewModel.selectedProject != resolvedProject - val revisionChanged = tryFoxViewModel.revision != deepLinkRevision - if (projectChanged || revisionChanged) { - tryFoxViewModel.setRevisionFromDeepLinkAndSearch(resolvedProject, deepLinkRevision) - } - } - } - - val binButtonEnabled = !isDownloading && cacheState == CacheManagementState.IdleNonEmpty - - Scaffold( - modifier = Modifier.fillMaxSize(), - topBar = { - TopAppBar( - title = { Text(stringResource(id = R.string.app_name)) }, - navigationIcon = { - IconButton(onClick = onNavigateUp) { - Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = stringResource(id = R.string.common_back_button_description)) - } - }, - actions = { - val tooltipState = rememberTooltipState() - TooltipBox( - positionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider(), - tooltip = { - PlainTooltip { - Text(stringResource(id = R.string.bin_button_tooltip_clear_downloaded_apks)) - } - }, - state = tooltipState, - ) { - BinButton( - cacheState = cacheState, - onConfirm = { tryFoxViewModel.clearAppCache() }, - enabled = binButtonEnabled, - ) - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.primaryContainer, - titleContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - actionIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, - navigationIconContentColor = MaterialTheme.colorScheme.onPrimaryContainer, // Added for consistency - ), - ) - }, - ) { innerPadding -> - LazyColumn( - modifier = Modifier - .fillMaxSize() - .padding(innerPadding), - contentPadding = PaddingValues(16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - item { - SearchSection( - selectedProject = tryFoxViewModel.selectedProject, - onProjectSelected = { actualProjectValue -> tryFoxViewModel.updateSelectedProject(actualProjectValue) }, - revision = tryFoxViewModel.revision, - onRevisionChange = { tryFoxViewModel.updateRevision(it) }, - onSearchClick = { tryFoxViewModel.searchJobsAndArtifacts() }, - isLoading = tryFoxViewModel.isLoading, - ) - } - - tryFoxViewModel.errorMessage?.let { - // TODO: Consider creating a specific string resource for \"Download failed\" if it's a common prefix for user-facing errors. - if (tryFoxViewModel.selectedJobs.isEmpty() || !it.startsWith("Download failed")) { - item { ErrorState(errorMessage = it) } - } - } - - tryFoxViewModel.relevantPushComment?.let { comment -> - val pushTimestamp = tryFoxViewModel.relevantPushTimestamp - if ((comment.isNotBlank() || tryFoxViewModel.relevantPushAuthor != null) && pushTimestamp != null) { - item { - PushCommentCard( - comment = comment, - author = tryFoxViewModel.relevantPushAuthor, - revision = tryFoxViewModel.revision, - pushTimestamp = pushTimestamp, - ) - } - } - } - - if (tryFoxViewModel.isLoading) { - item { - LoadingState(candidateCount = tryFoxViewModel.isLoadingJobArtifacts.size) - } - } else if (tryFoxViewModel.selectedJobs.isNotEmpty()) { - item { - Text( - text = stringResource(id = R.string.treeherder_apks_jobs_found_message, tryFoxViewModel.selectedJobs.size), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - modifier = Modifier - .padding(bottom = 8.dp) - .testTag(TREEHERDER_RESULTS_HEADER_TAG), - ) - } - - items(tryFoxViewModel.selectedJobs, key = { it.taskId }) { job -> - AppCard(job = job, viewModel = tryFoxViewModel) - } - } else if (!tryFoxViewModel.isLoading && tryFoxViewModel.errorMessage == null && (tryFoxViewModel.relevantPushComment != null || tryFoxViewModel.relevantPushAuthor != null)) { - // Slightly adjusted logic to account for author possibly being present even if comment is not - if (tryFoxViewModel.relevantPushComment?.isNotBlank() == true || tryFoxViewModel.relevantPushAuthor != null) { - // This case should ideally be handled by the PushCommentCard itself not rendering if both are empty/null - } else { - item { - Text( - stringResource(id = R.string.treeherder_apks_no_jobs_found), - style = MaterialTheme.typography.bodyLarge, - modifier = Modifier.padding(16.dp), - ) - } - } - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun SearchSection( - selectedProject: String, - onProjectSelected: (String) -> Unit, - revision: String, - onRevisionChange: (String) -> Unit, - onSearchClick: () -> Unit, - isLoading: Boolean, -) { - val projectDisplayOptions = projectDisplayToActualMap.keys.toList() - var expanded by remember { mutableStateOf(false) } - - Card( - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - ) { - Column( - modifier = Modifier.padding(16.dp), - verticalArrangement = Arrangement.spacedBy(12.dp), - ) { - Text( - text = stringResource(id = R.string.treeherder_apks_search_artifacts_title), - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - - Row( - modifier = Modifier - .fillMaxWidth() - .height(IntrinsicSize.Min), - verticalAlignment = Alignment.CenterVertically, - ) { - ExposedDropdownMenuBox( - expanded = expanded, - onExpandedChange = { expanded = !expanded }, - modifier = Modifier.weight(0.5f).fillMaxHeight(), - ) { - TextField( - value = projectActualToDisplayMap[selectedProject] ?: selectedProject, - onValueChange = {}, - readOnly = true, - label = { Text(stringResource(id = R.string.treeherder_apks_project_label)) }, - trailingIcon = { ExposedDropdownMenuDefaults.TrailingIcon(expanded = expanded) }, - modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable).fillMaxWidth(), - colors = OutlinedTextFieldDefaults.colors(), - ) - ExposedDropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - ) { - projectDisplayOptions.forEach { displayKey -> - DropdownMenuItem( - text = { Text(displayKey) }, - onClick = { - onProjectSelected(projectDisplayToActualMap[displayKey] ?: displayKey) - expanded = false - }, - ) - } - } - } - - Spacer(Modifier.width(8.dp)) - - OutlinedTextField( - value = revision, - onValueChange = onRevisionChange, - label = { Text(stringResource(id = R.string.treeherder_apks_revision_label)) }, - placeholder = { Text(stringResource(id = R.string.treeherder_apks_revision_placeholder)) }, - modifier = Modifier.weight(0.5f).fillMaxHeight(), - singleLine = true, - shape = RoundedCornerShape(topStart = 8.dp, bottomStart = 8.dp, topEnd = 0.dp, bottomEnd = 0.dp), // Matched ProfileScreen - colors = OutlinedTextFieldDefaults.colors(), - ) - - SearchButton( // Using the same SearchButton as ProfileScreen - onClick = onSearchClick, - enabled = !isLoading && revision.isNotBlank(), - isLoading = isLoading, - modifier = Modifier - .padding(top = 8.dp) - .fillMaxHeight(), - ) - } - } - } -} - -// Re-using the SearchButton from ProfileScreen implies it's either moved to a common composables location or defined here. -// For now, assuming it's defined in this file or accessible. If it was meant to be the ProfileScreen.SearchButton, -// this would need refactoring to a common composable. The current `SearchButton` defined below seems tailored for this screen. -@Composable -fun SearchButton( // This is the local SearchButton - onClick: () -> Unit, - enabled: Boolean, - isLoading: Boolean, - modifier: Modifier = Modifier, -) { - Button( - onClick = onClick, - enabled = enabled, - modifier = modifier, - shape = RoundedCornerShape(topStart = 0.dp, bottomStart = 0.dp, topEnd = 12.dp, bottomEnd = 12.dp), // Shape from Treeherder - colors = ButtonDefaults.buttonColors(containerColor = MaterialTheme.colorScheme.primary), - contentPadding = PaddingValues(horizontal = 0.dp), - ) { - if (isLoading) { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - color = MaterialTheme.colorScheme.onPrimary, - ) - } else { - Icon( - Icons.Default.Search, - contentDescription = stringResource(id = R.string.treeherder_apks_search_button_description), // Specific description - tint = MaterialTheme.colorScheme.onPrimary, - ) - } - } -} - -@Composable -fun LoadingState(candidateCount: Int) { - Card( - elevation = CardDefaults.cardElevation(defaultElevation = 8.dp), - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerHigh), - modifier = Modifier - .fillMaxWidth() - .testTag(TREEHERDER_LOADING_STATE_TAG), - ) { - Column( - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 20.dp, vertical = 24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - CircularProgressIndicator( - modifier = Modifier.size(36.dp), - strokeWidth = 3.dp, - ) - Column( - horizontalAlignment = Alignment.CenterHorizontally, - verticalArrangement = Arrangement.spacedBy(6.dp), - ) { - Text( - text = "Loading signed APKs", - style = MaterialTheme.typography.headlineSmall, - fontWeight = FontWeight.Bold, - ) - Text( - text = if (candidateCount > 0) { - "Inspecting $candidateCount candidate job${if (candidateCount == 1) "" else "s"} and resolving APK artifacts." - } else { - stringResource(id = R.string.treeherder_apks_loading_message) - }, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onSurfaceVariant, - ) - } - LinearProgressIndicator( - modifier = Modifier.fillMaxWidth(), - ) - } - } -} - -@Composable -fun ErrorState(errorMessage: String) { - Card( - colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.errorContainer), - modifier = Modifier.fillMaxWidth(), - ) { - Text( - text = errorMessage, - modifier = Modifier.padding(16.dp), - color = MaterialTheme.colorScheme.onErrorContainer, - style = MaterialTheme.typography.bodyMedium, - ) - } -} diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/TryFoxCardComponent.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/TryFoxCardComponent.kt index 4d8db1b..53053d4 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/TryFoxCardComponent.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/TryFoxCardComponent.kt @@ -3,6 +3,7 @@ package org.mozilla.tryfox.ui.screens import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.unit.Dp +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.AppUiModel @@ -11,7 +12,9 @@ fun TryFoxCardComponent( modifier: Modifier = Modifier, tryFoxApp: AppUiModel, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (java.io.File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, onDismiss: () -> Unit, onTryFoxCardHeightChange: (Dp) -> Unit, ) { @@ -20,6 +23,8 @@ fun TryFoxCardComponent( tryFoxApp = tryFoxApp, onDownloadClick = onDownloadClick, onInstallClick = onInstallClick, + installStates = installStates, + onOpenInstalledApp = onOpenInstalledApp, onDismiss = onDismiss, onTryFoxCardHeightChange = onTryFoxCardHeightChange, ) diff --git a/app/src/main/java/org/mozilla/tryfox/util/CommitMessageFormatter.kt b/app/src/main/java/org/mozilla/tryfox/util/CommitMessageFormatter.kt new file mode 100644 index 0000000..0f5925c --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/util/CommitMessageFormatter.kt @@ -0,0 +1,17 @@ +package org.mozilla.tryfox.util + +private val reviewerToken = "(?:#[A-Za-z0-9_-]+|[A-Za-z0-9_.-]+)" +private val trailingReviewerDirective = Regex( + "\\s+r[=?]\\s*$reviewerToken(?:\\s*,\\s*$reviewerToken)*!?\\s*$", + RegexOption.IGNORE_CASE, +) + +/** Removes trailing Phabricator reviewer metadata from a commit subject for display. */ +internal fun String.withoutTrailingReviewerDirective(): String { + val subjectEnd = indexOfFirst { it == '\n' || it == '\r' }.let { index -> + if (index == -1) length else index + } + val subject = substring(0, subjectEnd) + val description = substring(subjectEnd) + return trailingReviewerDirective.replace(subject, "").trimEnd() + description +} diff --git a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt index 736e68a..e795522 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt @@ -3,9 +3,13 @@ package org.mozilla.tryfox.util const val FENIX = "fenix" const val FENIX_RELEASE = "fenix-release" const val FENIX_BETA = "fenix-beta" +const val FENIX_DEBUG = "fenix-debug" const val FENIX_NIGHTLY = "fenix-nightly" const val FOCUS = "focus" const val FOCUS_RELEASE = "focus-release" +const val FOCUS_NIGHTLY = "focus-nightly" +const val FOCUS_BETA = "focus-beta" +const val FOCUS_DEBUG = "focus-debug" const val REFERENCE_BROWSER = "reference-browser" const val TREEHERDER = "treeherder" const val TRYFOX = "TryFox" @@ -13,7 +17,9 @@ const val TRYFOX = "TryFox" const val FENIX_NIGHTLY_PACKAGE = "org.mozilla.fenix" const val FENIX_RELEASE_PACKAGE = "org.mozilla.firefox" const val FENIX_BETA_PACKAGE = "org.mozilla.firefox_beta" +const val FENIX_DEBUG_PACKAGE = "org.mozilla.fenix.debug" const val FOCUS_NIGHTLY_PACKAGE = "org.mozilla.focus.nightly" +const val FOCUS_BETA_PACKAGE = "org.mozilla.focus.beta" const val FOCUS_RELEASE_PACKAGE = "org.mozilla.focus" const val REFERENCE_BROWSER_PACKAGE = "org.mozilla.reference.browser" const val TRYFOX_PACKAGE = "org.mozilla.tryfox" diff --git a/app/src/main/res/drawable/ic_fenix_beta_foreground.xml b/app/src/main/res/drawable/ic_fenix_beta_foreground.xml new file mode 100644 index 0000000..d08cf87 --- /dev/null +++ b/app/src/main/res/drawable/ic_fenix_beta_foreground.xml @@ -0,0 +1,221 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_fenix_debug_foreground.xml b/app/src/main/res/drawable/ic_fenix_debug_foreground.xml new file mode 100644 index 0000000..ca6a894 --- /dev/null +++ b/app/src/main/res/drawable/ic_fenix_debug_foreground.xml @@ -0,0 +1,125 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_fenix_nightly_foreground.xml b/app/src/main/res/drawable/ic_fenix_nightly_foreground.xml new file mode 100644 index 0000000..77e0035 --- /dev/null +++ b/app/src/main/res/drawable/ic_fenix_nightly_foreground.xml @@ -0,0 +1,205 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_focus_beta_foreground.xml b/app/src/main/res/drawable/ic_focus_beta_foreground.xml new file mode 100644 index 0000000..84b58b6 --- /dev/null +++ b/app/src/main/res/drawable/ic_focus_beta_foreground.xml @@ -0,0 +1,259 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_focus_debug_foreground.png b/app/src/main/res/drawable/ic_focus_debug_foreground.png new file mode 100644 index 0000000..4a56210 Binary files /dev/null and b/app/src/main/res/drawable/ic_focus_debug_foreground.png differ diff --git a/app/src/main/res/drawable/ic_focus_debug_foreground_v2.png b/app/src/main/res/drawable/ic_focus_debug_foreground_v2.png new file mode 100644 index 0000000..9718bf4 Binary files /dev/null and b/app/src/main/res/drawable/ic_focus_debug_foreground_v2.png differ diff --git a/app/src/main/res/drawable/ic_focus_nightly_foreground.xml b/app/src/main/res/drawable/ic_focus_nightly_foreground.xml new file mode 100644 index 0000000..8b11a50 --- /dev/null +++ b/app/src/main/res/drawable/ic_focus_nightly_foreground.xml @@ -0,0 +1,253 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_tryfox_black.png b/app/src/main/res/drawable/ic_tryfox_black.png new file mode 100644 index 0000000..81d229e Binary files /dev/null and b/app/src/main/res/drawable/ic_tryfox_black.png differ diff --git a/app/src/main/res/drawable/ic_usb_c_cable.xml b/app/src/main/res/drawable/ic_usb_c_cable.xml new file mode 100644 index 0000000..5d5f15c --- /dev/null +++ b/app/src/main/res/drawable/ic_usb_c_cable.xml @@ -0,0 +1,23 @@ + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/unknown_app.xml b/app/src/main/res/drawable/unknown_app.xml new file mode 100644 index 0000000..21556b3 --- /dev/null +++ b/app/src/main/res/drawable/unknown_app.xml @@ -0,0 +1,62 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f4e3b3a..caff014 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,31 +1,48 @@ TryFox - Profile - Search Treeherder + Search builds History + Settings + Recent searches Scan QR code Receive from desktop Fetching latest nightly builds... Loading initial app data... + Firefox Android + Fenix + Focus for Android + Privacy Browser + Reference Browser + GeckoView Reference + No APKs available + Try revision · %1$s + Select build An unknown error occurred. Back - Search Fenix Artifacts + Settings + Cache + Downloaded APKs stored on this device. + Current space used + Clear cache + Clearing cache… + Cache can’t be cleared while a download is in progress. + Clear cache? + Downloaded APKs will be removed from this device. + Clear cache + Cancel + Home screen layout + Choose how apps are grouped on Home. + One card per app + One card per flavor of each app + Search builds Project - Revision - Revision hash... - Found %1$d job(s) matching criteria: - No jobs found matching the specified criteria for this push. + Email or revision + Email address or revision... Search Searching for jobs and artifacts... + Loading APK builds Unknown Warning: Unsupported ABI - Task ID: %1$s - Loading artifacts for this job... - No APKs found for this job. - APKs supported by your device: - APKs unsupported by your device (%1$d) - Collapse - Expand Download failed: %1$s Firefox Nightly Icon Firefox Icon @@ -33,10 +50,17 @@ Focus Icon Reference Browser Icon App Icon + TryFox Icon Confirm Clear Cache Clear Cache Clear downloaded apks Install + Installing… + Open + Replace installed app? + An incompatible or newer version of %1$s is installed. Uninstalling it deletes that app’s local data before this build is installed. + Uninstall and install + Cancel Downloading Download No APK details available. @@ -44,17 +68,28 @@ Not supported by your device (%1$d) No APKs found for this date. No APKs found for this release. - Profile + Search builds Loading pushes… No pushes found for this author. + + %1$d push found + %1$d pushes found + Job: Task ID: Compatible APKs No compatible APKs found for this job. - Try pushes - User email - Search pushes by email - Enter a user email and tap search to find pushes. + + %1$d unsigned apk + %1$d unsigned apks + + Unsigned APKs expanded + Unsigned APKs collapsed + Those APKs are unsigned and might not be installable correctly. You can trigger a signing job on Treeherder, like `signing-apk-fenix-nightly`. + Search builds + Revision or email + Search builds + Enter an email or revision and tap search. Clear email field History No history yet @@ -69,6 +104,7 @@ Fenix Beta Focus Nightly Focus Release + Focus Beta Reference Browser Unsupported ABI Clear date selection @@ -140,4 +176,9 @@ Push timestamp: %1$s Open TryFox to inspect the received message. Reason: %1$s + TryFox downloads + Keeps APK downloads running in the background. + %1$s download + Downloading in the background + %1$d%% complete diff --git a/app/src/main/res/xml/provider_paths.xml b/app/src/main/res/xml/provider_paths.xml deleted file mode 100644 index 0d76511..0000000 --- a/app/src/main/res/xml/provider_paths.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/app/src/test/java/org/mozilla/tryfox/AppDeepLinkRouteMapperTest.kt b/app/src/test/java/org/mozilla/tryfox/AppDeepLinkRouteMapperTest.kt index d3d983f..bc2b239 100644 --- a/app/src/test/java/org/mozilla/tryfox/AppDeepLinkRouteMapperTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/AppDeepLinkRouteMapperTest.kt @@ -34,12 +34,12 @@ class AppDeepLinkRouteMapperTest { } @Test - fun `maps scanned author link to encoded profile route`() { + fun `maps scanned author link to encoded unified search route`() { val route = AppDeepLinkRouteMapper.routeFor( "tryfox://jobs?author=try%2Buser%40mozilla.com", ) - assertEquals("profile_by_email?email=try%2Buser%40mozilla.com", route) + assertEquals("treeherder_search/try/try%2Buser%40mozilla.com", route) } @Test diff --git a/app/src/test/java/org/mozilla/tryfox/TryFoxViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/TryFoxViewModelTest.kt index b5a0dd3..f71728a 100644 --- a/app/src/test/java/org/mozilla/tryfox/TryFoxViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/TryFoxViewModelTest.kt @@ -14,6 +14,10 @@ import org.junit.jupiter.api.BeforeEach import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mozilla.tryfox.data.Artifact import org.mozilla.tryfox.data.ArtifactsResponse import org.mozilla.tryfox.data.DownloadState @@ -27,8 +31,9 @@ import org.mozilla.tryfox.data.RevisionResult import org.mozilla.tryfox.data.TreeherderJobsResponse import org.mozilla.tryfox.data.TreeherderRevisionResponse import org.mozilla.tryfox.data.managers.FakeCacheManager -import org.mozilla.tryfox.data.managers.FakeIntentManager import org.mozilla.tryfox.data.repositories.TreeherderRepository +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.TryBuildProvenance import org.mozilla.tryfox.ui.screens.MainCoroutineRule import java.io.File @@ -254,11 +259,11 @@ class TryFoxViewModelTest { artifactsByTaskId = mapOf("history-task" to listOf(apkArtifact("public/build/target.arm64-v8a.apk"))), ) val historyRepository = FakeHistoryRepository() - val intentManager = FakeIntentManager() + val installCoordinator = mock() val viewModel = createViewModel( repository = repository, historyRepository = historyRepository, - intentManager = intentManager, + installCoordinator = installCoordinator, currentTimeMillisProvider = { 123L }, ) @@ -279,7 +284,10 @@ class TryFoxViewModelTest { assertEquals("arm64-v8a", historyEntry.abiName) assertEquals(123L, historyEntry.historyRecordedTimestamp) assertEquals(123L, historyEntry.lastInstallerLaunchTimestamp) - assertTrue(intentManager.wasInstallApkCalled) + val provenance = argumentCaptor() + verify(installCoordinator).install(eq(downloadedArtifact.uniqueKey), eq(downloadedFile), provenance.capture()) + assertEquals("mozilla-central", provenance.firstValue.project) + assertEquals("ed209aa2136b241686ff20489c5cb622348e2ecf", provenance.firstValue.revision) } @Test @@ -369,7 +377,7 @@ class TryFoxViewModelTest { private fun createViewModel( repository: TreeherderRepository, historyRepository: FakeHistoryRepository = FakeHistoryRepository(), - intentManager: FakeIntentManager = FakeIntentManager(), + installCoordinator: ApkInstallCoordinator = mock(), downloadFileRepository: FakeDownloadFileRepository = FakeDownloadFileRepository( downloadProgressDelayMillis = 0, ), @@ -378,7 +386,7 @@ class TryFoxViewModelTest { fenixRepository = repository, downloadFileRepository = downloadFileRepository, cacheManager = cacheManager, - intentManager = intentManager, + installCoordinator = installCoordinator, historyRepository = historyRepository, project = "mozilla-central", revision = null, diff --git a/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt index 0ed5d71..239575e 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeDownloadFileRepository.kt @@ -16,7 +16,7 @@ class FakeDownloadFileRepository( override suspend fun downloadFile( downloadUrl: String, outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, + onProgress: suspend (bytesDownloaded: Long, totalBytes: Long) -> Unit, ): NetworkResult { downloadFileCalled = true diff --git a/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt index cff038b..ed2c413 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt @@ -16,6 +16,9 @@ class FakeMozillaArchiveRepository( private val focusReleases: NetworkResult> = NetworkResult.Success(emptyList()), private val focusReleaseVersions: NetworkResult> = NetworkResult.Success(emptyList()), private val focusReleasesByVersion: Map>> = emptyMap(), + private val focusBetaReleases: NetworkResult> = NetworkResult.Success(emptyList()), + private val focusBetaVersions: NetworkResult> = NetworkResult.Success(emptyList()), + private val focusBetaReleasesByVersion: Map>> = emptyMap(), ) : MozillaArchiveRepository { override suspend fun getFenixNightlyBuilds(date: LocalDate?): NetworkResult> { @@ -52,4 +55,11 @@ class FakeMozillaArchiveRepository( override suspend fun getFocusReleaseBuildsForVersion(version: String): NetworkResult> { return focusReleasesByVersion[version] ?: focusReleases } + + override suspend fun getFocusBetaBuilds(): NetworkResult> = focusBetaReleases + + override suspend fun getFocusBetaVersions(): NetworkResult> = focusBetaVersions + + override suspend fun getFocusBetaBuildsForVersion(version: String): NetworkResult> = + focusBetaReleasesByVersion[version] ?: focusBetaReleases } diff --git a/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt b/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt index 51812bb..20606b9 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/MozillaArchiveRepositoryImplTest.kt @@ -222,4 +222,20 @@ class MozillaArchiveRepositoryImplTest { assertTrue(result is NetworkResult.Error) assertEquals("Failed to fetch or parse focus builds: $errorMessage", (result as NetworkResult.Error).message) } + + @Test + fun `getFocusBetaVersions returns beta versions only in newest-first order`() = runTest { + val releasesHtml = """ + 146.0/ + 147.0b2/ + 147.0b7/ + """.trimIndent() + whenever(mockMozillaArchivesApiService.getHtmlPage(eq(DefaultMozillaArchiveRepository.RELEASES_FOCUS_BASE_URL))) + .thenReturn(releasesHtml) + + val result = repository.getFocusBetaVersions() + + assertTrue(result is NetworkResult.Success) + assertEquals(listOf("147.0b7", "147.0b2"), (result as NetworkResult.Success).data) + } } diff --git a/app/src/test/java/org/mozilla/tryfox/data/SearchHistoryTest.kt b/app/src/test/java/org/mozilla/tryfox/data/SearchHistoryTest.kt new file mode 100644 index 0000000..4ba2dd4 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/SearchHistoryTest.kt @@ -0,0 +1,47 @@ +package org.mozilla.tryfox.data + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class SearchHistoryTest { + + @Test + fun `records newest first, deduplicates normalized project and query, and limits entries`() { + val initialEntries = (1..15).map { index -> + SearchHistoryEntry("try", "revision-$index", SearchHistoryQueryType.REVISION, index.toLong()) + } + + val updatedEntries = SearchHistory.record( + entries = initialEntries, + entry = SearchHistoryEntry("TRY", " Revision-5 ", SearchHistoryQueryType.REVISION, 20L), + ) + + assertEquals(15, updatedEntries.size) + assertEquals("Revision-5", updatedEntries.first().query) + assertEquals("revision-15", updatedEntries.last().query) + } + + @Test + fun `places the latest email before newer revision entries`() { + val entries = listOf( + SearchHistoryEntry("try", "revision", SearchHistoryQueryType.REVISION, 30L), + SearchHistoryEntry("mozilla-central", "older@mozilla.org", SearchHistoryQueryType.EMAIL, 20L), + SearchHistoryEntry("try", "old-revision", SearchHistoryQueryType.REVISION, 10L), + ) + + assertEquals( + listOf("older@mozilla.org", "revision", "old-revision"), + SearchHistory.displayOrder(entries).map(SearchHistoryEntry::query), + ) + assertEquals("older@mozilla.org", SearchHistory.latestEmail(entries)) + } + + @Test + fun `creates a legacy email entry only when history is empty`() { + val legacyEntry = SearchHistory.legacyEmailEntry("person@mozilla.org") + + assertEquals("person@mozilla.org", legacyEntry?.query) + assertEquals(SearchHistoryQueryType.EMAIL, legacyEntry?.queryType) + assertEquals(null, SearchHistory.legacyEmailEntry(" ")) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt index 52d5bdb..9980566 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/DefaultCacheManagerTest.kt @@ -1,5 +1,7 @@ package org.mozilla.tryfox.data.managers +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertTrue @@ -35,4 +37,47 @@ class DefaultCacheManagerTest { assertTrue(unrelatedCacheFile.exists()) assertFalse(File(managedRoot, "image_cache/cached-image").exists()) } + + @Test + fun `cache status reports recursive size and clearing resets it`() = runTest { + val managedRoot = File(tempDir, "download-cache") + File(managedRoot, "fenix/nested/first.apk").apply { + parentFile?.mkdirs() + writeBytes(ByteArray(15)) + } + File(managedRoot, "treeherder/second.apk").apply { + parentFile?.mkdirs() + writeBytes(ByteArray(9)) + } + val manager = DefaultCacheManager(managedRoot, StandardTestDispatcher(testScheduler)) + + manager.checkCacheStatus() + + assertEquals(24L, manager.cacheSizeBytes.value) + assertEquals(org.mozilla.tryfox.model.CacheManagementState.IdleNonEmpty, manager.cacheState.value) + + manager.clearCache() + + assertEquals(0L, manager.cacheSizeBytes.value) + assertEquals(org.mozilla.tryfox.model.CacheManagementState.IdleEmpty, manager.cacheState.value) + } + + @Test + fun `zero byte cache files are still clearable`() = runTest { + val managedRoot = File(tempDir, "download-cache") + File(managedRoot, "fenix/empty.apk").apply { + parentFile?.mkdirs() + createNewFile() + } + val manager = DefaultCacheManager(managedRoot, StandardTestDispatcher(testScheduler)) + + manager.checkCacheStatus() + + assertEquals(0L, manager.cacheSizeBytes.value) + assertEquals(org.mozilla.tryfox.model.CacheManagementState.IdleNonEmpty, manager.cacheState.value) + + manager.clearCache() + + assertFalse(File(managedRoot, "fenix/empty.apk").exists()) + } } diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeCacheManager.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeCacheManager.kt index adfcc0f..bede0fb 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeCacheManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeCacheManager.kt @@ -10,6 +10,8 @@ class FakeCacheManager(private val cacheDir: File) : CacheManager { private val _cacheState = MutableStateFlow(CacheManagementState.IdleEmpty) override val cacheState: StateFlow = _cacheState.asStateFlow() + private val _cacheSizeBytes = MutableStateFlow(0L) + override val cacheSizeBytes: StateFlow = _cacheSizeBytes.asStateFlow() var clearCacheCalled = false private set @@ -22,12 +24,18 @@ class FakeCacheManager(private val cacheDir: File) : CacheManager { override suspend fun clearCache() { clearCacheCalled = true + cacheDir.listFiles()?.forEach { child -> + if (child.isDirectory) { + child.deleteRecursively() + } + } // Simulate the behavior of DefaultCacheManager: set to Clearing then to IdleEmpty _cacheState.value = CacheManagementState.Clearing + _cacheSizeBytes.value = 0L _cacheState.value = CacheManagementState.IdleEmpty } - override fun checkCacheStatus() { + override suspend fun checkCacheStatus() { checkCacheStatusCalled = true // Allow tests to manually set the state or simulate a specific outcome } @@ -42,11 +50,17 @@ class FakeCacheManager(private val cacheDir: File) : CacheManager { _cacheState.value = state } + fun setCacheSizeBytes(sizeBytes: Long) { + _cacheSizeBytes.value = sizeBytes + _cacheState.value = if (sizeBytes > 0) CacheManagementState.IdleNonEmpty else CacheManagementState.IdleEmpty + } + fun reset() { clearCacheCalled = false checkCacheStatusCalled = false getCacheDirCalledWith = null appCachePopulatedResult = false _cacheState.value = CacheManagementState.IdleEmpty + _cacheSizeBytes.value = 0L } } 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 index cc287f0..d408289 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeIntentManager.kt @@ -1,31 +1,12 @@ 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 - var wasUninstallApkCalled: 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 - } - override fun uninstallApk(packageName: String) { wasUninstallApkCalled = 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 index 0ff690d..cf8e8f3 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt @@ -2,8 +2,12 @@ package org.mozilla.tryfox.data.managers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import org.mozilla.tryfox.data.SearchHistory +import org.mozilla.tryfox.data.SearchHistoryEntry +import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.lan.LanReceiveIdentity +import org.mozilla.tryfox.model.HomeScreenLayout /** * A fake implementation of [UserDataRepository] for testing purposes. @@ -12,19 +16,37 @@ class FakeUserDataRepository : UserDataRepository { private val _lastSearchedEmailFlow = MutableStateFlow("") override val lastSearchedEmailFlow: Flow = _lastSearchedEmailFlow + private val _searchHistoryFlow = MutableStateFlow>(emptyList()) + override val searchHistoryFlow: Flow> = _searchHistoryFlow private val _lanReceiveIdentityFlow = MutableStateFlow(null) override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow + private val _homeScreenLayoutFlow = MutableStateFlow(HomeScreenLayout.OneCardPerApp) + override val homeScreenLayoutFlow: Flow = _homeScreenLayoutFlow override suspend fun saveLastSearchedEmail(email: String) { - _lastSearchedEmailFlow.value = email + recordSearch("try", email) + } + + override suspend fun recordSearch(project: String, query: String, searchedAt: Long) { + val queryType = if ('@' in query) SearchHistoryQueryType.EMAIL else SearchHistoryQueryType.REVISION + _searchHistoryFlow.value = SearchHistory.record( + _searchHistoryFlow.value, + SearchHistoryEntry(project, query, queryType, searchedAt), + ) + _lastSearchedEmailFlow.value = SearchHistory.latestEmail(_searchHistoryFlow.value) } override suspend fun saveLanReceiveIdentity(identity: LanReceiveIdentity) { _lanReceiveIdentityFlow.value = identity } + override suspend fun saveHomeScreenLayout(layout: HomeScreenLayout) { + _homeScreenLayoutFlow.value = layout + } + // Helper method for tests to clear the stored email if needed fun clearLastSearchedEmail() { _lastSearchedEmailFlow.value = "" + _searchHistoryFlow.value = emptyList() } } diff --git a/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepositoryTest.kt b/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepositoryTest.kt new file mode 100644 index 0000000..12454c8 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeDataCacheRepositoryTest.kt @@ -0,0 +1,82 @@ +package org.mozilla.tryfox.data.repositories + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.io.TempDir +import org.mockito.kotlin.doReturn +import org.mockito.kotlin.mock +import java.io.File + +class HomeDataCacheRepositoryTest { + @TempDir + lateinit var filesDir: File + + private fun repository() = DefaultHomeDataCacheRepository( + context = mock { on { this.filesDir } doReturn filesDir }, + ioDispatcher = Dispatchers.Unconfined, + ) + + private fun snapshot(version: String = "1.0") = HomeDataSnapshot( + version = HomeDataSnapshot.CURRENT_VERSION, + apps = listOf( + CachedHomeApp( + appName = "fenix", + apks = listOf( + CachedHomeApk( + originalString = "build", + rawDateString = "2026-07-31-10-00-00", + appName = "fenix", + version = version, + abiName = "arm64-v8a", + fullUrl = "https://example.test/fenix.apk", + fileName = "fenix.apk", + ), + ), + ), + ), + ) + + @Test + fun `read returns null when no snapshot exists`() = runTest { + assertNull(repository().read()) + } + + @Test + fun `write then read round trips a snapshot`() = runTest { + val repository = repository() + val snapshot = snapshot() + + repository.write(snapshot) + + assertEquals(snapshot, repository.read()) + } + + @Test + fun `write replaces the prior snapshot`() = runTest { + val repository = repository() + repository.write(snapshot("1.0")) + val replacement = snapshot("2.0") + + repository.write(replacement) + + assertEquals(replacement, repository.read()) + } + + @Test + fun `read ignores corrupt snapshots`() = runTest { + File(filesDir, "home-data-cache-v1.json").writeText("not json") + + assertNull(repository().read()) + } + + @Test + fun `read ignores snapshots without a schema version`() = runTest { + File(filesDir, "home-data-cache-v1.json").writeText("{\"apps\":[]}") + + assertNull(repository().read()) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeScreenLayoutPreferenceTest.kt b/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeScreenLayoutPreferenceTest.kt new file mode 100644 index 0000000..98e925d --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/data/repositories/HomeScreenLayoutPreferenceTest.kt @@ -0,0 +1,21 @@ +package org.mozilla.tryfox.data.repositories + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.model.HomeScreenLayout + +class HomeScreenLayoutPreferenceTest { + @Test + fun `missing or invalid layout defaults to one card per app`() { + assertEquals(HomeScreenLayout.OneCardPerApp, homeScreenLayoutFromStoredValue(null)) + assertEquals(HomeScreenLayout.OneCardPerApp, homeScreenLayoutFromStoredValue("unknown")) + } + + @Test + fun `stored layout is restored`() { + assertEquals( + HomeScreenLayout.OneCardPerFlavor, + homeScreenLayoutFromStoredValue(HomeScreenLayout.OneCardPerFlavor.name), + ) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt b/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt new file mode 100644 index 0000000..08b64af --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/install/ApkInstallCoordinatorTest.kt @@ -0,0 +1,110 @@ +package org.mozilla.tryfox.install + +import android.content.Context +import android.content.Intent +import android.content.IntentSender +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mockito.Mockito.mockConstruction +import org.mockito.kotlin.any +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mozilla.tryfox.data.InstalledTryBuild +import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE +import java.io.ByteArrayOutputStream +import java.io.File + +class ApkInstallCoordinatorTest { + + @Test + fun `session factory creates and commits a PackageInstaller session owned by TryFox`() { + val packageInstaller = mock() + val session = mock() + val statusReceiver = mock() + val apk = File.createTempFile("tryfox-install", ".apk").apply { writeText("apk") } + val apkSize = apk.length() + whenever(packageInstaller.createSession(any())).thenReturn(42) + whenever(packageInstaller.openSession(42)).thenReturn(session) + whenever(session.openWrite(any(), any(), any())).thenReturn(ByteArrayOutputStream()) + + val paramsConstruction = mockConstruction(PackageInstaller.SessionParams::class.java) { _, context -> + assertEquals(PackageInstaller.SessionParams.MODE_FULL_INSTALL, context.arguments().single()) + } + try { + PackageInstallerSessionFactory(packageInstaller).commit(apk, "org.mozilla.fenix", statusReceiver) + + val params = paramsConstruction.constructed().single() + verify(params).setAppPackageName("org.mozilla.fenix") + verify(params).setSize(apkSize) + } finally { + paramsConstruction.close() + apk.delete() + } + + verify(packageInstaller).createSession(any()) + verify(session).openWrite("base.apk", 0L, apkSize) + verify(session).fsync(any()) + verify(session).commit(statusReceiver) + } + + @Test + fun `successful result after process recreation persists Try build provenance`() = runTest { + val packageInstaller = mock() + val packageManager = mock() + val context = mock() + whenever(context.packageManager).thenReturn(packageManager) + whenever(packageManager.packageInstaller).thenReturn(packageInstaller) + val repository = RecordingInstalledTryBuildRepository() + val coordinator = ApkInstallCoordinator(context, repository) + val resultIntent = mock() + val extras = mapOf( + "org.mozilla.tryfox.install.ARTIFACT_KEY" to "artifact-key", + "org.mozilla.tryfox.install.PACKAGE_NAME" to FENIX_DEBUG_PACKAGE, + "org.mozilla.tryfox.install.VERSION_NAME" to "128.0a1", + "org.mozilla.tryfox.install.PROJECT" to "mobile", + "org.mozilla.tryfox.install.REVISION" to "abc123", + "org.mozilla.tryfox.install.COMMIT_MESSAGE" to "Fix the Fenix Debug build", + ) + whenever(resultIntent.getStringExtra(any())).thenAnswer { invocation -> + extras[invocation.getArgument(0)] + } + whenever(resultIntent.getLongExtra(any(), any())).thenAnswer { invocation -> + if (invocation.getArgument(0) == "org.mozilla.tryfox.install.VERSION_CODE") 123L + else invocation.getArgument(1) + } + whenever(resultIntent.getIntExtra(any(), any())).thenAnswer { invocation -> + if (invocation.getArgument(0) == PackageInstaller.EXTRA_STATUS) PackageInstaller.STATUS_SUCCESS + else invocation.getArgument(1) + } + + coordinator.onInstallResult(resultIntent) + + assertEquals( + InstalledTryBuild( + packageName = FENIX_DEBUG_PACKAGE, + project = "mobile", + revision = "abc123", + commitMessage = "Fix the Fenix Debug build", + versionName = "128.0a1", + versionCode = 123L, + ), + repository.savedBuild, + ) + } + + private class RecordingInstalledTryBuildRepository : InstalledTryBuildRepository { + override val installedTryBuild: Flow = flowOf(null) + var savedBuild: InstalledTryBuild? = null + + override suspend fun save(build: InstalledTryBuild) { + savedBuild = build + } + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/model/AppStateTest.kt b/app/src/test/java/org/mozilla/tryfox/model/AppStateTest.kt new file mode 100644 index 0000000..8e40f35 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/model/AppStateTest.kt @@ -0,0 +1,28 @@ +package org.mozilla.tryfox.model + +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class AppStateTest { + @Test + fun `identifies installed apps from unknown sources as sideloaded`() { + val sideloadedApp = appState(installer = "com.google.android.packageinstaller") + + assertTrue(sideloadedApp.isSideloaded) + } + + @Test + fun `does not classify Play Store or TryFox installs as sideloaded`() { + assertFalse(appState(AppState.PLAY_STORE_PACKAGE).isSideloaded) + assertFalse(appState(AppState.TRYFOX_PACKAGE).isSideloaded) + } + + private fun appState(installer: String?) = AppState( + name = "Firefox", + packageName = "org.mozilla.firefox", + version = "1.0", + installDateMillis = 0L, + installingPackageName = installer, + ) +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt new file mode 100644 index 0000000..3b95392 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/composables/AppIconTest.kt @@ -0,0 +1,54 @@ +package org.mozilla.tryfox.ui.composables + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.R +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_DEBUG +import org.mozilla.tryfox.util.FOCUS_NIGHTLY +import org.mozilla.tryfox.util.FOCUS_RELEASE + +class AppIconTest { + + @Test + fun `uses the Fenix Debug icon for the Fenix Debug flavor`() { + val (icon, description) = appIconResources( + appName = FENIX_DEBUG, + useSearchResultVariant = false, + ) + + assertEquals(R.drawable.ic_fenix_debug_foreground, icon) + assertEquals(R.string.app_icon_firefox_description, description) + } + + @Test + fun `uses matching Home icons for Focus flavors`() { + val expectedIcons = mapOf( + FOCUS_RELEASE to R.drawable.ic_focus, + FOCUS_BETA to R.drawable.ic_focus_beta_foreground, + FOCUS to R.drawable.ic_focus_nightly_foreground, + FOCUS_NIGHTLY to R.drawable.ic_focus_nightly_foreground, + FOCUS_DEBUG to R.drawable.ic_focus_debug_foreground_v2, + ) + + expectedIcons.forEach { (appName, expectedIcon) -> + val (icon, description) = appIconResources(appName, useSearchResultVariant = false) + + assertEquals(expectedIcon, icon, "Unexpected icon for $appName") + assertEquals(R.string.app_icon_focus_description, description) + } + } + + @Test + fun `uses the unknown app icon for an unrecognized app`() { + val (icon, description) = appIconResources( + appName = "roam", + useSearchResultVariant = true, + ) + + assertEquals(R.drawable.unknown_app, icon) + assertEquals(R.string.app_icon_generic_description, description) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/AndroidApkCandidateTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/AndroidApkCandidateTest.kt new file mode 100644 index 0000000..62caed1 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/AndroidApkCandidateTest.kt @@ -0,0 +1,20 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.data.JobDetails + +class AndroidApkCandidateTest { + + @Test + fun `includes unsigned Roam APK build jobs`() { + val job = JobDetails( + appName = "roam", + jobName = "build-apk-roam-debug", + jobSymbol = "B", + taskId = "roam-task", + ) + + assertTrue(isAndroidApkCandidate(job)) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/ApkJobOrderingTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/ApkJobOrderingTest.kt new file mode 100644 index 0000000..e351c77 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/ApkJobOrderingTest.kt @@ -0,0 +1,64 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.ui.models.JobDetailsUiModel + +class ApkJobOrderingTest { + + @Test + fun `orders regular jobs before firebase and perftest jobs`() { + val fenixRegular = job(appName = "fenix", jobName = "signing-apk-fenix-nightly") + val focusRegular = job(appName = "focus", jobName = "signing-apk-focus-nightly") + val fenixFirebase = job(appName = "fenix", jobName = "signing-apk-fenix-nightly-firebase") + val focusFirebase = job(appName = "focus", jobName = "signing-apk-focus-nightly-firebase") + val fenixPerftest = job(appName = "fenix", jobName = "signing-apk-fenix-nightly-simulation") + val focusPerftest = job(appName = "focus", jobName = "signing-apk-focus-nightly-perftest") + + assertEquals( + listOf(fenixRegular, focusRegular, fenixFirebase, focusFirebase, fenixPerftest, focusPerftest), + orderApkJobs( + listOf( + focusPerftest, + focusFirebase, + fenixRegular, + focusRegular, + fenixPerftest, + fenixFirebase, + ), + ), + ) + } + + @Test + fun `orders jobs alphabetically by app then job name within each variant group`() { + val fenixBeta = job(appName = "Fenix", jobName = "signing-apk-fenix-beta") + val fenixNightly = job(appName = "fenix", jobName = "signing-apk-fenix-nightly") + val focusNightly = job(appName = "focus", jobName = "signing-apk-focus-nightly") + + assertEquals( + listOf(fenixBeta, fenixNightly, focusNightly), + orderApkJobs(listOf(focusNightly, fenixNightly, fenixBeta)), + ) + } + + @Test + fun `treats a firebase perftest as a perftest`() { + val firebase = job(appName = "fenix", jobName = "signing-apk-fenix-nightly-firebase") + val firebasePerftest = job(appName = "fenix", jobName = "signing-apk-fenix-nightly-firebase-perftest") + + assertEquals( + listOf(firebase, firebasePerftest), + orderApkJobs(listOf(firebasePerftest, firebase)), + ) + } + + private fun job(appName: String, jobName: String) = JobDetailsUiModel( + appName = appName, + jobName = jobName, + jobSymbol = "B", + taskId = jobName, + isSignedBuild = true, + isTest = false, + ) +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt index d724a54..e5cfd3d 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt @@ -5,8 +5,10 @@ import kotlinx.coroutines.flow.emptyFlow import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.util.FENIX_BETA_PACKAGE +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE import org.mozilla.tryfox.util.FENIX_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE_PACKAGE +import org.mozilla.tryfox.util.FOCUS_BETA_PACKAGE import org.mozilla.tryfox.util.FOCUS_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FOCUS_RELEASE_PACKAGE import org.mozilla.tryfox.util.REFERENCE_BROWSER_PACKAGE @@ -25,12 +27,18 @@ class FakeMozillaPackageManager( override val fenixBeta: AppState get() = apps[FENIX_BETA_PACKAGE] ?: AppState("Firefox Beta", FENIX_BETA_PACKAGE, null, null) + override val fenixDebug: AppState + get() = apps[FENIX_DEBUG_PACKAGE] ?: AppState("Firefox Debug", FENIX_DEBUG_PACKAGE, null, null) + override val focus: AppState get() = apps[FOCUS_NIGHTLY_PACKAGE] ?: AppState("Focus Nightly", FOCUS_NIGHTLY_PACKAGE, null, null) override val focusRelease: AppState get() = apps[FOCUS_RELEASE_PACKAGE] ?: AppState("Focus Release", FOCUS_RELEASE_PACKAGE, null, null) + override val focusBeta: AppState + get() = apps[FOCUS_BETA_PACKAGE] ?: AppState("Focus Beta", FOCUS_BETA_PACKAGE, null, null) + override val referenceBrowser: AppState get() = apps[REFERENCE_BROWSER_PACKAGE] ?: AppState("Reference Browser", REFERENCE_BROWSER_PACKAGE, null, null) diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt index 96ab561..e595627 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt @@ -1,20 +1,34 @@ package org.mozilla.tryfox.ui.screens import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.junit.jupiter.api.extension.RegisterExtension import org.junit.jupiter.api.io.TempDir +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever import org.mozilla.tryfox.data.DownloadState -import org.mozilla.tryfox.data.FakeDownloadFileRepository import org.mozilla.tryfox.data.FakeHistoryRepository import org.mozilla.tryfox.data.TreeherderInstallHistoryEntry import org.mozilla.tryfox.data.managers.FakeCacheManager -import org.mozilla.tryfox.data.managers.FakeIntentManager +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.TryBuildProvenance import java.io.File @OptIn(ExperimentalCoroutinesApi::class) @@ -27,6 +41,70 @@ class HistoryViewModelTest { @TempDir lateinit var tempCacheDir: File + private class FakeApkDownloadCoordinator : ApkDownloadCoordinator { + private val _downloads = MutableStateFlow>(emptyMap()) + val enqueuedRequests = mutableListOf() + val canceledKeys = mutableSetOf() + private val currentWorkIds = mutableMapOf() + private val canceledWorkIds = mutableSetOf() + private var workIdSequence = 0 + + override val downloads = _downloads.asStateFlow() + + override fun enqueue(request: ApkDownloadRequest): String { + enqueuedRequests += request + val workId = "${request.uniqueKey}#${++workIdSequence}" + currentWorkIds[request.uniqueKey] = workId + updateState( + request.uniqueKey, + request.toPersistedState(DownloadStatus.QUEUED, workId), + ) + return workId + } + + override fun retry(request: ApkDownloadRequest): String = enqueue(request) + + override fun cancel(uniqueKey: String) { + canceledKeys += uniqueKey + currentWorkIds[uniqueKey]?.let { canceledWorkIds += it } + _downloads.value[uniqueKey]?.let { current -> + updateState( + uniqueKey, + current.copy(status = DownloadStatus.CANCELED, updatedAt = System.currentTimeMillis()), + ) + } + } + + override fun observe(uniqueKey: String) = downloads.map { it[uniqueKey] } + + fun emit(state: PersistedDownloadState) { + val currentWorkId = currentWorkIds[state.uniqueKey] ?: return + if (state.workId != currentWorkId || state.workId in canceledWorkIds) { + return + } + updateState(state.uniqueKey, state) + } + + private fun updateState(uniqueKey: String, state: PersistedDownloadState) { + _downloads.value = _downloads.value + (uniqueKey to state) + } + + private fun ApkDownloadRequest.toPersistedState( + status: DownloadStatus, + workId: String? = null, + ): PersistedDownloadState = + PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + workId = workId, + ) + } + @Test fun `history item uses downloaded state when apk exists in cache`() = runTest { val entry = historyEntry() @@ -68,16 +146,42 @@ class HistoryViewModelTest { fun `download uses stored url and writes apk to treeherder cache`() = runTest { val entry = historyEntry(downloadUrl = "https://example.com/artifact.apk") val cacheManager = FakeCacheManager(tempCacheDir) + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) }, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) + val enqueuedRequest = downloadCoordinator.enqueuedRequests.single() + val workId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertEquals(entry.downloadUrl, enqueuedRequest.downloadUrl) + assertNotNull(workId) + val downloadedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") + downloadedFile.parentFile?.mkdirs() + downloadedFile.writeText("downloaded apk from ${entry.downloadUrl}") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = workId, + ), + ) + advanceUntilIdle() + assertTrue(downloadedFile.exists()) assertTrue(downloadedFile.readText().contains(entry.downloadUrl)) assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.Downloaded) @@ -87,15 +191,37 @@ class HistoryViewModelTest { fun `download retries when rendered item is not downloaded but remembered downloaded file is missing`() = runTest { val entry = historyEntry(downloadUrl = "https://example.com/artifact.apk") val cacheManager = FakeCacheManager(tempCacheDir) + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) }, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) val downloadedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") + val firstWorkId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertNotNull(firstWorkId) + downloadedFile.parentFile?.mkdirs() + downloadedFile.writeText("downloaded apk") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = firstWorkId, + ), + ) + advanceUntilIdle() assertTrue(downloadedFile.delete()) viewModel.refreshCachedDownloadStates() advanceUntilIdle() @@ -104,6 +230,26 @@ class HistoryViewModelTest { viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(2, downloadCoordinator.enqueuedRequests.size) + val secondWorkId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertNotNull(secondWorkId) + downloadedFile.writeText("downloaded apk again") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = secondWorkId, + ), + ) + advanceUntilIdle() + assertTrue(downloadedFile.exists()) assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.Downloaded) } @@ -112,22 +258,57 @@ class HistoryViewModelTest { fun `in progress download state is kept even if output file already exists`() = runTest { val entry = historyEntry() val cacheManager = FakeCacheManager(tempCacheDir) - val blockingDownloadRepository = BlockingDownloadFileRepository() + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) }, - downloadFileRepository = blockingDownloadRepository, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) + val enqueuedRequest = downloadCoordinator.enqueuedRequests.single() + val workId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertNotNull(workId) + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}").absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.RUNNING, + bytesDownloaded = 1L, + totalBytes = 10L, + workId = workId, + ), + ) + val cachedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") + cachedFile.parentFile?.mkdirs() + cachedFile.writeText("partial") assertTrue(cachedFile.exists()) assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.InProgress) - blockingDownloadRepository.complete() + cachedFile.writeText("complete") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = cachedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = cachedFile.length(), + totalBytes = cachedFile.length(), + workId = workId, + ), + ) advanceUntilIdle() assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.Downloaded) @@ -137,7 +318,7 @@ class HistoryViewModelTest { fun `install records a fresh installer launch timestamp before launching installer`() = runTest { val entry = historyEntry(lastInstallerLaunchTimestamp = 1L) val historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) } - val intentManager = FakeIntentManager() + val installCoordinator = installCoordinator() val cacheManager = FakeCacheManager(tempCacheDir) val cachedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") cachedFile.parentFile?.mkdirs() @@ -145,7 +326,7 @@ class HistoryViewModelTest { val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = historyRepository, - intentManager = intentManager, + installCoordinator = installCoordinator, currentTimeMillisProvider = { 123L }, ) advanceUntilIdle() @@ -154,7 +335,9 @@ class HistoryViewModelTest { advanceUntilIdle() assertEquals(123L, historyRepository.recordedEntries.single().lastInstallerLaunchTimestamp) - assertTrue(intentManager.wasInstallApkCalled) + val provenance = argumentCaptor() + verify(installCoordinator).install(eq(entry.uniqueKey), eq(cachedFile), provenance.capture()) + assertEquals(entry.project, provenance.firstValue.project) } @Test @@ -164,7 +347,7 @@ class HistoryViewModelTest { setEntries(listOf(entry)) failUpsertHistoryEntry = true } - val intentManager = FakeIntentManager() + val installCoordinator = installCoordinator() val cacheManager = FakeCacheManager(tempCacheDir) val cachedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") cachedFile.parentFile?.mkdirs() @@ -172,14 +355,14 @@ class HistoryViewModelTest { val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = historyRepository, - intentManager = intentManager, + installCoordinator = installCoordinator, ) advanceUntilIdle() viewModel.install(viewModel.historyItems.value.single(), cachedFile) advanceUntilIdle() - assertTrue(intentManager.wasInstallApkCalled) + verify(installCoordinator).install(eq(entry.uniqueKey), eq(cachedFile), org.mockito.kotlin.any()) } @Test @@ -208,28 +391,31 @@ class HistoryViewModelTest { val entry = historyEntry() val cacheManager = FakeCacheManager(tempCacheDir) val historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) } - val downloadFileRepository = CancellationIgnoringFailingDownloadFileRepository() + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = historyRepository, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.InProgress) val downloadedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") val partialFile = File(downloadedFile.parentFile, "${downloadedFile.name}.part") val managedBackupFile = File(downloadedFile.parentFile, "${downloadedFile.name}.bak.1") val unmanagedBackupLikeFile = File(downloadedFile.parentFile, "${downloadedFile.name}.bak.tmp") + downloadedFile.parentFile?.mkdirs() + partialFile.writeText("partial") managedBackupFile.writeText("backup") unmanagedBackupLikeFile.writeText("not managed by downloader") viewModel.delete(viewModel.historyItems.value.single()) advanceUntilIdle() - assertTrue(downloadFileRepository.wasCanceled) + assertTrue(downloadCoordinator.canceledKeys.contains(entry.uniqueKey)) assertEquals(emptyList(), historyRepository.recordedEntries) assertTrue(viewModel.historyItems.value.isEmpty()) assertFalse(downloadedFile.exists()) @@ -237,6 +423,20 @@ class HistoryViewModelTest { assertFalse(managedBackupFile.exists()) assertTrue(unmanagedBackupLikeFile.exists()) + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = 10L, + totalBytes = 10L, + workId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId, + ), + ) historyRepository.setEntries(listOf(entry)) advanceUntilIdle() @@ -248,46 +448,80 @@ class HistoryViewModelTest { val entry = historyEntry() val cacheManager = FakeCacheManager(tempCacheDir) val historyRepository = FakeHistoryRepository().apply { setEntries(listOf(entry)) } - val downloadFileRepository = DelayedCanceledThenBlockingDownloadFileRepository() + val downloadCoordinator = FakeApkDownloadCoordinator() val viewModel = createViewModel( cacheManager = cacheManager, historyRepository = historyRepository, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = downloadCoordinator, ) advanceUntilIdle() viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() + assertEquals(1, downloadCoordinator.enqueuedRequests.size) + val firstRequest = downloadCoordinator.enqueuedRequests.single() + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}").absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.RUNNING, + bytesDownloaded = 1L, + totalBytes = 10L, + workId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId, + ), + ) viewModel.delete(viewModel.historyItems.value.single()) advanceUntilIdle() historyRepository.setEntries(listOf(entry)) advanceUntilIdle() - assertTrue( - (viewModel.historyItems.value.single().downloadState as DownloadState.InProgress) - .isIndeterminate, - ) + assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.NotDownloaded) viewModel.download(viewModel.historyItems.value.single()) advanceUntilIdle() val downloadedFile = File(cacheManager.getCacheDir("treeherder"), "${entry.taskId}/${entry.artifactFileName}") - val partialFile = File(downloadedFile.parentFile, "${downloadedFile.name}.part") - assertEquals(1, downloadFileRepository.startedDownloads) - assertFalse(partialFile.exists()) - - downloadFileRepository.completeCanceledDownload() - advanceUntilIdle() - assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.NotDownloaded) - - viewModel.download(viewModel.historyItems.value.single()) + assertEquals(2, downloadCoordinator.enqueuedRequests.size) + val secondRequest = downloadCoordinator.enqueuedRequests.last() + val secondWorkId = downloadCoordinator.downloads.value[entry.uniqueKey]?.workId + assertNotNull(secondWorkId) + downloadedFile.parentFile?.mkdirs() + downloadedFile.writeText("second complete") + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = secondWorkId, + ), + ) advanceUntilIdle() + assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.Downloaded) - assertTrue(partialFile.exists()) - assertEquals(2, downloadFileRepository.startedDownloads) - assertTrue(viewModel.historyItems.value.single().downloadState is DownloadState.InProgress) - - downloadFileRepository.completeSecondDownload() + downloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = entry.uniqueKey, + downloadUrl = entry.downloadUrl, + outputPath = downloadedFile.absolutePath, + appName = entry.appName, + fileName = entry.artifactFileName, + cacheRelativePath = entry.cacheRelativePath, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = downloadedFile.length(), + totalBytes = downloadedFile.length(), + workId = secondWorkId, + ), + ) advanceUntilIdle() assertTrue(downloadedFile.exists()) @@ -297,20 +531,24 @@ class HistoryViewModelTest { private fun createViewModel( cacheManager: FakeCacheManager, historyRepository: FakeHistoryRepository, - downloadFileRepository: org.mozilla.tryfox.data.repositories.DownloadFileRepository = - FakeDownloadFileRepository(downloadProgressDelayMillis = 0), - intentManager: FakeIntentManager = FakeIntentManager(), + downloadCoordinator: ApkDownloadCoordinator = FakeApkDownloadCoordinator(), + installCoordinator: ApkInstallCoordinator = installCoordinator(), currentTimeMillisProvider: () -> Long = { 0L }, ): HistoryViewModel = HistoryViewModel( historyRepository = historyRepository, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = downloadCoordinator, cacheManager = cacheManager, - intentManager = intentManager, + installCoordinator = installCoordinator, ioDispatcher = mainCoroutineRule.testDispatcher, currentTimeMillisProvider = currentTimeMillisProvider, ) + private fun installCoordinator(): ApkInstallCoordinator = mock().also { coordinator -> + whenever(coordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(coordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + } + private fun historyEntry( downloadUrl: String = "https://example.com/task/artifact", historyRecordedTimestamp: Long = 10L, @@ -336,100 +574,4 @@ class HistoryViewModelTest { historyRecordedTimestamp = historyRecordedTimestamp, lastInstallerLaunchTimestamp = lastInstallerLaunchTimestamp, ) - - private class BlockingDownloadFileRepository : org.mozilla.tryfox.data.repositories.DownloadFileRepository { - private val completion = kotlinx.coroutines.CompletableDeferred() - - override suspend fun downloadFile( - downloadUrl: String, - outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, - ): org.mozilla.tryfox.data.NetworkResult { - outputFile.parentFile?.mkdirs() - outputFile.writeText("partial") - onProgress(1L, 10L) - completion.await() - outputFile.writeText("complete") - onProgress(10L, 10L) - return org.mozilla.tryfox.data.NetworkResult.Success(outputFile) - } - - fun complete() { - completion.complete(Unit) - } - } - - private class CancellationIgnoringFailingDownloadFileRepository : org.mozilla.tryfox.data.repositories.DownloadFileRepository { - var wasCanceled = false - private set - - override suspend fun downloadFile( - downloadUrl: String, - outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, - ): org.mozilla.tryfox.data.NetworkResult { - outputFile.parentFile?.mkdirs() - outputFile.writeText("partial") - File(outputFile.parentFile, "${outputFile.name}.part").writeText("partial") - onProgress(1L, 10L) - - try { - kotlinx.coroutines.awaitCancellation() - } catch (_: kotlinx.coroutines.CancellationException) { - wasCanceled = true - } - - outputFile.writeText("late complete") - File(outputFile.parentFile, "${outputFile.name}.part").writeText("late partial") - onProgress(10L, 10L) - return org.mozilla.tryfox.data.NetworkResult.Error("late failure after cancellation", null) - } - } - - private class DelayedCanceledThenBlockingDownloadFileRepository : org.mozilla.tryfox.data.repositories.DownloadFileRepository { - private val canceledDownloadCanComplete = kotlinx.coroutines.CompletableDeferred() - private val secondDownloadCanComplete = kotlinx.coroutines.CompletableDeferred() - - var startedDownloads = 0 - private set - - override suspend fun downloadFile( - downloadUrl: String, - outputFile: File, - onProgress: (bytesDownloaded: Long, totalBytes: Long) -> Unit, - ): org.mozilla.tryfox.data.NetworkResult { - startedDownloads += 1 - outputFile.parentFile?.mkdirs() - val partialFile = File(outputFile.parentFile, "${outputFile.name}.part") - - return if (startedDownloads == 1) { - partialFile.writeText("first partial") - onProgress(1L, 10L) - try { - kotlinx.coroutines.awaitCancellation() - } catch (_: kotlinx.coroutines.CancellationException) { - kotlinx.coroutines.withContext(kotlinx.coroutines.NonCancellable) { - canceledDownloadCanComplete.await() - } - } - org.mozilla.tryfox.data.NetworkResult.Error("first download canceled", null) - } else { - partialFile.writeText("second partial") - onProgress(1L, 10L) - secondDownloadCanComplete.await() - partialFile.delete() - outputFile.writeText("second complete") - onProgress(10L, 10L) - org.mozilla.tryfox.data.NetworkResult.Success(outputFile) - } - } - - fun completeCanceledDownload() { - canceledDownloadCanComplete.complete(Unit) - } - - fun completeSecondDownload() { - secondDownloadCanComplete.complete(Unit) - } - } } diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt new file mode 100644 index 0000000..fe6f7e0 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt @@ -0,0 +1,89 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.model.HomeScreenLayout +import org.mozilla.tryfox.ui.models.ApksResult +import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_RELEASE +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_DEBUG +import org.mozilla.tryfox.util.FOCUS_RELEASE +import org.mozilla.tryfox.util.REFERENCE_BROWSER + +class HomeAppCardModelTest { + @Test + fun `groups all home flavors into three cards with mockup defaults`() { + val apps = listOf( + FENIX, FENIX_BETA, FENIX_RELEASE, FOCUS, FOCUS_BETA, FOCUS_RELEASE, REFERENCE_BROWSER, + ).associateWith(::app) + + val cards = homeAppCards(apps, emptyMap()) + + assertEquals(3, cards.size) + assertEquals(FENIX, cards.first { it.family == HomeAppFamily.Fenix }.selectedAppName) + assertEquals(FOCUS_BETA, cards.first { it.family == HomeAppFamily.Focus }.selectedAppName) + assertEquals(REFERENCE_BROWSER, cards.first { it.family == HomeAppFamily.ReferenceBrowser }.selectedAppName) + } + + @Test + fun `retains an explicitly selected flavor`() { + val apps = listOf(FENIX, FENIX_BETA, FENIX_RELEASE).associateWith(::app) + + val card = homeAppCards(apps, mapOf(HomeAppFamily.Fenix to FENIX_RELEASE)).single() + + assertEquals(FENIX_RELEASE, card.selectedAppName) + } + + @Test + fun `only shows Debug flavors when Firefox Debug is installed`() { + val apps = listOf(FENIX, FENIX_BETA, FENIX_RELEASE, FENIX_DEBUG, FOCUS, FOCUS_BETA, FOCUS_RELEASE, FOCUS_DEBUG) + .associateWith(::app) + + val cardsWithoutDebug = homeAppCards(apps, emptyMap()) + assertEquals(false, FENIX_DEBUG in cardsWithoutDebug.first { it.family == HomeAppFamily.Fenix }.appsByName) + assertEquals(false, FOCUS_DEBUG in cardsWithoutDebug.first { it.family == HomeAppFamily.Focus }.appsByName) + + val appsWithDebug = apps + (FENIX_DEBUG to app(FENIX_DEBUG, installedVersion = "1.0")) + + (FOCUS_DEBUG to app(FOCUS_DEBUG, installedVersion = "1.0")) + val cardsWithDebug = homeAppCards(appsWithDebug, emptyMap()) + assertEquals(true, FENIX_DEBUG in cardsWithDebug.first { it.family == HomeAppFamily.Fenix }.appsByName) + assertEquals(true, FOCUS_DEBUG in cardsWithDebug.first { it.family == HomeAppFamily.Focus }.appsByName) + } + + @Test + fun `creates one standalone card for each available flavor`() { + val apps = listOf( + FENIX, FENIX_BETA, FENIX_RELEASE, FENIX_DEBUG, + FOCUS, FOCUS_BETA, FOCUS_RELEASE, FOCUS_DEBUG, + REFERENCE_BROWSER, + ).associateWith(::app) + mapOf( + FENIX_DEBUG to app(FENIX_DEBUG, installedVersion = "1.0"), + FOCUS_DEBUG to app(FOCUS_DEBUG, installedVersion = "1.0"), + ) + + val cards = homeAppCards(apps, emptyMap(), HomeScreenLayout.OneCardPerFlavor) + + assertEquals( + listOf( + FENIX_RELEASE, FENIX_BETA, FENIX, FENIX_DEBUG, + FOCUS_RELEASE, FOCUS_BETA, FOCUS, FOCUS_DEBUG, + REFERENCE_BROWSER, + ), + cards.map { it.selectedAppName }, + ) + assertEquals(true, cards.all { !it.showFlavorSelector }) + } + + private fun app(name: String, installedVersion: String? = null) = AppUiModel( + name = name, + packageName = name, + installedVersion = installedVersion, + installedDate = null, + apks = ApksResult.Loading, + ) +} 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 fc80873..809691a 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 @@ -1,13 +1,14 @@ package org.mozilla.tryfox.ui.screens +import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.map import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import kotlinx.datetime.LocalDate -import kotlinx.datetime.LocalDateTime -import kotlinx.datetime.format -import kotlinx.datetime.format.FormatStringsInDatetimeFormats -import kotlinx.datetime.format.byUnicodePattern import org.junit.jupiter.api.AfterEach import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertFalse @@ -19,34 +20,50 @@ 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.junit.jupiter.MockitoSettings -import org.mockito.kotlin.any import org.mockito.kotlin.eq +import org.mockito.kotlin.isNull +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify import org.mockito.kotlin.whenever import org.mockito.quality.Strictness import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.FakeMozillaArchiveRepository import org.mozilla.tryfox.data.FakeReferenceBrowserReleaseRepository import org.mozilla.tryfox.data.FakeTryFoxReleaseRepository +import org.mozilla.tryfox.data.InstalledTryBuild import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.FakeCacheManager import org.mozilla.tryfox.data.managers.FakeIntentManager -import org.mozilla.tryfox.data.repositories.DownloadFileRepository +import org.mozilla.tryfox.data.managers.FakeUserDataRepository +import org.mozilla.tryfox.data.repositories.CachedHomeApk +import org.mozilla.tryfox.data.repositories.CachedHomeApp +import org.mozilla.tryfox.data.repositories.DateAwareReleaseRepository import org.mozilla.tryfox.data.repositories.FenixReleaseReleaseRepository import org.mozilla.tryfox.data.repositories.FenixReleaseRepository import org.mozilla.tryfox.data.repositories.FocusNightlyRepository import org.mozilla.tryfox.data.repositories.FocusReleaseRepository +import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository +import org.mozilla.tryfox.data.repositories.HomeDataSnapshot +import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.model.CacheManagementState +import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_DEBUG +import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.FOCUS_RELEASE @@ -57,7 +74,6 @@ import java.io.File @ExperimentalCoroutinesApi @ExtendWith(MockitoExtension::class) @MockitoSettings(strictness = Strictness.LENIENT) -@OptIn(FormatStringsInDatetimeFormats::class) class HomeViewModelTest { @JvmField @@ -66,10 +82,9 @@ class HomeViewModelTest { private lateinit var viewModel: HomeViewModel private lateinit var fakeCacheManager: FakeCacheManager - - @Mock - private lateinit var downloadFileRepository: DownloadFileRepository + private lateinit var fakeDownloadCoordinator: FakeApkDownloadCoordinator private val intentManager = FakeIntentManager() + private lateinit var installCoordinator: ApkInstallCoordinator @TempDir lateinit var tempCacheDir: File @@ -143,6 +158,7 @@ class HomeViewModelTest { return ApkUiModel( originalString = parsed.originalString, date = dateFormatted, + buildDate = parsed.rawDateString?.rawNightlyBuildDate(), appName = parsed.appName, version = parsed.version, abi = AbiUiModel(parsed.abiName, true), @@ -174,32 +190,215 @@ class HomeViewModelTest { @BeforeEach fun setUp() { fakeCacheManager = FakeCacheManager(tempCacheDir) + fakeDownloadCoordinator = FakeApkDownloadCoordinator() + installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) viewModel = createViewModel() } private fun createViewModel( releaseRepositories: List = emptyList(), mozillaPackageManager: MozillaPackageManager = FakeMozillaPackageManager(), + userDataRepository: FakeUserDataRepository? = null, + homeDataCacheRepository: HomeDataCacheRepository = FakeHomeDataCacheRepository(), + installedTryBuildRepository: InstalledTryBuildRepository = FakeInstalledTryBuildRepository(), ) = HomeViewModel( releaseRepositories = releaseRepositories, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = fakeDownloadCoordinator, mozillaPackageManager = mozillaPackageManager, cacheManager = fakeCacheManager, intentManager = intentManager, + installCoordinator = installCoordinator, ioDispatcher = mainCoroutineRule.testDispatcher, + userDataRepository = userDataRepository, + homeDataCacheRepository = homeDataCacheRepository, + installedTryBuildRepository = installedTryBuildRepository, supportedAbis = listOf("arm64-v8a", "x86_64", "armeabi-v7a"), ) - private fun String.formatApkDateForTest(): String { - return try { - val inputFormat = LocalDateTime.Format { byUnicodePattern("yyyy-MM-dd-HH-mm-ss") } - val outputFormat = LocalDateTime.Format { byUnicodePattern("yyyy-MM-dd HH:mm:ss") } - LocalDateTime.parse(this, inputFormat).format(outputFormat) - } catch (e: Exception) { - this + @Test + fun `layout preference updates the loaded home state`() = runTest { + val userDataRepository = FakeUserDataRepository() + val viewModel = createViewModel(userDataRepository = userDataRepository) + + viewModel.initialLoad() + advanceUntilIdle() + userDataRepository.saveHomeScreenLayout(HomeScreenLayout.OneCardPerFlavor) + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertEquals(HomeScreenLayout.OneCardPerFlavor, state.homeScreenLayout) + } + + private class FakeHomeDataCacheRepository( + var snapshot: HomeDataSnapshot? = null, + ) : HomeDataCacheRepository { + val writes = mutableListOf() + + override suspend fun read(): HomeDataSnapshot? = snapshot + + override suspend fun write(snapshot: HomeDataSnapshot) { + writes += snapshot + this.snapshot = snapshot + } + } + + private class FakeInstalledTryBuildRepository( + build: InstalledTryBuild? = null, + ) : InstalledTryBuildRepository { + private val state = MutableStateFlow(build) + override val installedTryBuild = state.asStateFlow() + + override suspend fun save(build: InstalledTryBuild) { + state.value = build + } + } + + @Test + fun `shows Try build provenance only when Fenix Debug version matches`() = runTest { + val build = InstalledTryBuild( + packageName = FENIX_DEBUG_PACKAGE, + project = "try", + revision = "abc123", + commitMessage = "Bug 123: debug build", + versionName = "145.0a1", + versionCode = 42L, + ) + val packageManager = FakeMozillaPackageManager( + mapOf( + FENIX_DEBUG_PACKAGE to AppState( + "Firefox Debug", + FENIX_DEBUG_PACKAGE, + "145.0a1", + 1L, + versionCode = 42L, + ), + ), + ) + val viewModel = createViewModel( + mozillaPackageManager = packageManager, + installedTryBuildRepository = FakeInstalledTryBuildRepository(build), + ) + + viewModel.initialLoad() + advanceUntilIdle() + + val apps = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps + assertEquals(build, apps.getValue(FENIX_DEBUG).installedTryBuild) + + val mismatchedPackageManager = FakeMozillaPackageManager( + mapOf( + FENIX_DEBUG_PACKAGE to AppState( + "Firefox Debug", + FENIX_DEBUG_PACKAGE, + "145.0a1", + 1L, + versionCode = 43L, + ), + ), + ) + val mismatchedViewModel = createViewModel( + mozillaPackageManager = mismatchedPackageManager, + installedTryBuildRepository = FakeInstalledTryBuildRepository(build), + ) + mismatchedViewModel.initialLoad() + advanceUntilIdle() + + val mismatchedApps = (mismatchedViewModel.homeScreenState.value as HomeScreenState.Loaded).apps + assertNull(mismatchedApps.getValue(FENIX_DEBUG).installedTryBuild) + } + + private class CountingReleaseRepository( + override val appName: String, + private val result: NetworkResult>, + ) : ReleaseRepository { + var calls = 0 + + override suspend fun getLatestReleases(): NetworkResult> { + calls += 1 + return result } } + private class BlockingDateReleaseRepository( + override val appName: String, + private val latestResult: NetworkResult>, + private val dateResult: NetworkResult>, + ) : DateAwareReleaseRepository { + val latestStarted = CompletableDeferred() + val unblockLatest = CompletableDeferred() + var latestCalls = 0 + + override suspend fun getLatestReleases(): NetworkResult> { + latestCalls += 1 + latestStarted.complete(Unit) + unblockLatest.await() + return latestResult + } + + override suspend fun getReleases(date: LocalDate?): NetworkResult> = dateResult + } + + private class FakeApkDownloadCoordinator : ApkDownloadCoordinator { + private val _downloads = MutableStateFlow>(emptyMap()) + val enqueuedRequests = mutableListOf() + + override val downloads = _downloads.asStateFlow() + + override fun enqueue(request: ApkDownloadRequest): String { + enqueuedRequests += request + updateState( + request.uniqueKey, + request.toPersistedState( + status = DownloadStatus.QUEUED, + workId = request.uniqueKey, + ), + ) + return request.uniqueKey + } + + override fun retry(request: ApkDownloadRequest): String = enqueue(request) + + override fun cancel(uniqueKey: String) { + _downloads.value[uniqueKey]?.let { current -> + updateState( + uniqueKey, + current.copy( + status = DownloadStatus.CANCELED, + updatedAt = System.currentTimeMillis(), + ), + ) + } + } + + override fun observe(uniqueKey: String) = downloads.map { it[uniqueKey] } + + fun emit(state: PersistedDownloadState) { + updateState(state.uniqueKey, state) + } + + private fun updateState(uniqueKey: String, state: PersistedDownloadState) { + _downloads.value = _downloads.value + (uniqueKey to state) + } + + private fun ApkDownloadRequest.toPersistedState( + status: DownloadStatus, + workId: String? = null, + ): PersistedDownloadState = + PersistedDownloadState( + uniqueKey = uniqueKey, + downloadUrl = downloadUrl, + outputPath = outputPath, + appName = appName, + fileName = fileName, + cacheRelativePath = cacheRelativePath, + status = status, + workId = workId, + ) + } + + private fun String.formatApkDateForTest(): String = formatNightlyBuildDate() + @AfterEach fun tearDown() { fakeCacheManager.reset() @@ -214,6 +413,160 @@ class HomeViewModelTest { ) } + @Test + fun `initialLoad hydrates cached data and retains it when refresh fails`() = runTest { + val cachedApk = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) + val cache = FakeHomeDataCacheRepository( + HomeDataSnapshot( + version = HomeDataSnapshot.CURRENT_VERSION, + apps = listOf( + CachedHomeApp( + appName = testFenixAppName, + apks = listOf( + CachedHomeApk( + cachedApk.originalString, + cachedApk.rawDateString, + cachedApk.appName, + cachedApk.version, + cachedApk.abiName, + cachedApk.fullUrl, + cachedApk.fileName, + ), + ), + ), + ), + ), + ) + val repository = CountingReleaseRepository( + testFenixAppName, + NetworkResult.Error("offline"), + ) + viewModel = createViewModel(listOf(repository), homeDataCacheRepository = cache) + + viewModel.initialLoad() + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertTrue(state.apps[testFenixAppName]?.apks is ApksResult.Success) + assertEquals(1, repository.calls) + assertEquals(1, cache.writes.size) + } + + @Test + fun `initialLoad is idempotent after home view model has loaded`() = runTest { + val repository = CountingReleaseRepository( + testFenixAppName, + NetworkResult.Success(emptyList()), + ) + viewModel = createViewModel(listOf(repository)) + + viewModel.initialLoad() + advanceUntilIdle() + viewModel.initialLoad() + advanceUntilIdle() + + assertEquals(1, repository.calls) + } + + @Test + fun `refresh waits for an in-flight load before starting another request`() = runTest { + val repository = BlockingDateReleaseRepository( + testFenixAppName, + NetworkResult.Success(emptyList()), + NetworkResult.Success(emptyList()), + ) + viewModel = createViewModel(listOf(repository)) + + viewModel.initialLoad() + runCurrent() + assertTrue(repository.latestStarted.isCompleted) + viewModel.refreshData() + runCurrent() + + assertEquals(1, repository.latestCalls) + assertTrue(viewModel.isRefreshing.value) + + repository.unblockLatest.complete(Unit) + advanceUntilIdle() + + assertEquals(2, repository.latestCalls) + assertFalse(viewModel.isRefreshing.value) + } + + @Test + fun `refresh retains the selected home app flavor`() = runTest { + val repository = CountingReleaseRepository( + testFenixAppName, + NetworkResult.Success(emptyList()), + ) + viewModel = createViewModel(listOf(repository)) + + viewModel.initialLoad() + advanceUntilIdle() + viewModel.selectHomeAppFlavor(HomeAppFamily.Fenix, FENIX_RELEASE) + + viewModel.refreshData() + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertEquals(FENIX_RELEASE, state.selectedAppNames[HomeAppFamily.Fenix]) + } + + @Test + fun `date selection is retained when cache refresh finishes later`() = runTest { + val cachedApk = createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi) + val selectedApk = createTestParsedNightlyApk( + testFenixAppName, + "2023-10-30-01-01-01", + testVersion, + testAbi, + ) + val cache = FakeHomeDataCacheRepository( + HomeDataSnapshot( + version = HomeDataSnapshot.CURRENT_VERSION, + apps = listOf( + CachedHomeApp( + appName = testFenixAppName, + apks = listOf( + CachedHomeApk( + cachedApk.originalString, + cachedApk.rawDateString, + cachedApk.appName, + cachedApk.version, + cachedApk.abiName, + cachedApk.fullUrl, + cachedApk.fileName, + ), + ), + ), + ), + ), + ) + val repository = BlockingDateReleaseRepository( + testFenixAppName, + NetworkResult.Success(listOf(cachedApk)), + NetworkResult.Success(listOf(selectedApk)), + ) + viewModel = createViewModel(listOf(repository), homeDataCacheRepository = cache) + val selectedDate = LocalDate(2023, 10, 30) + + viewModel.initialLoad() + runCurrent() + assertTrue(repository.latestStarted.isCompleted) + + viewModel.onDateSelected(testFenixAppName, selectedDate) + runCurrent() + repository.unblockLatest.complete(Unit) + advanceUntilIdle() + + val state = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertEquals(selectedDate, state.apps[testFenixAppName]?.userPickedDate) + assertEquals( + selectedApk.rawDateString?.formatApkDateForTest(), + (state.apps[testFenixAppName]?.apks as? ApksResult.Success)?.apks?.single()?.date, + ) + } + @Test fun `initialLoad success should update HomeScreenState to Loaded with data`() = runTest { val fenixParsed = @@ -561,25 +914,45 @@ class HomeViewModelTest { val initialLoadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded assertTrue(initialLoadedState.apps[FENIX]!!.apks is ApksResult.Success) - whenever( - downloadFileRepository.downloadFile(eq(apkToDownload.url), eq(expectedApkFile), any()), - ).thenAnswer { invocation -> - val onProgress = invocation.arguments[2] as (Long, Long) -> Unit - onProgress(50L, 100L) - val parentDir = expectedApkFile.parentFile - if (parentDir != null && !parentDir.exists()) { - parentDir.mkdirs() - } - expectedApkFile.createNewFile() - NetworkResult.Success(expectedApkFile) - } - viewModel.downloadNightlyApk(apkToDownload) advanceUntilIdle() - val loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded - val fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success - val downloadedApkInfo = + assertEquals(1, fakeDownloadCoordinator.enqueuedRequests.size) + val enqueuedRequest = fakeDownloadCoordinator.enqueuedRequests.first() + assertEquals(apkToDownload.uniqueKey, enqueuedRequest.uniqueKey) + + var loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded + var fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success + var downloadedApkInfo = + fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } + + assertNotNull(downloadedApkInfo, "Queued APK info should not be null") + assertTrue( + downloadedApkInfo!!.downloadState is DownloadState.InProgress, + "DownloadState should be InProgress while work is queued", + ) + + expectedApkFile.parentFile?.mkdirs() + expectedApkFile.writeText("downloaded apk") + fakeDownloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = apkToDownload.uniqueKey, + downloadUrl = apkToDownload.url, + outputPath = expectedApkFile.absolutePath, + appName = apkToDownload.appName, + fileName = apkToDownload.fileName, + cacheRelativePath = null, + status = DownloadStatus.SUCCEEDED, + bytesDownloaded = expectedApkFile.length(), + totalBytes = expectedApkFile.length(), + workId = enqueuedRequest.uniqueKey, + ), + ) + advanceUntilIdle() + + loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded + fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success + downloadedApkInfo = fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } assertNotNull(downloadedApkInfo, "Downloaded APK info should not be null") @@ -592,7 +965,6 @@ class HomeViewModelTest { (downloadedApkInfo.downloadState as DownloadState.Downloaded).file.path, ) assertTrue(fakeCacheManager.checkCacheStatusCalled) - assertTrue(intentManager.wasInstallApkCalled) assertFalse( loadedState.isDownloadingAnyFile, "isDownloadingAnyFile should be false after success", @@ -618,13 +990,25 @@ class HomeViewModelTest { val initialLoadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded assertTrue(initialLoadedState.apps[FENIX]!!.apks is ApksResult.Success) - whenever( - downloadFileRepository.downloadFile(eq(apkToDownload.url), eq(expectedApkFile), any()), - ).thenAnswer { NetworkResult.Error(downloadErrorMessage) } - viewModel.downloadNightlyApk(apkToDownload) advanceUntilIdle() + assertEquals(1, fakeDownloadCoordinator.enqueuedRequests.size) + fakeDownloadCoordinator.emit( + PersistedDownloadState( + uniqueKey = apkToDownload.uniqueKey, + downloadUrl = apkToDownload.url, + outputPath = expectedApkFile.absolutePath, + appName = apkToDownload.appName, + fileName = apkToDownload.fileName, + cacheRelativePath = null, + status = DownloadStatus.FAILED, + errorMessage = downloadErrorMessage, + workId = fakeDownloadCoordinator.enqueuedRequests.first().uniqueKey, + ), + ) + advanceUntilIdle() + val loadedState = viewModel.homeScreenState.value as HomeScreenState.Loaded val fenixBuildsState = loadedState.apps[FENIX]!!.apks as ApksResult.Success val failedApkInfo = fenixBuildsState.apks.find { it.uniqueKey == apkToDownload.uniqueKey } @@ -749,4 +1133,24 @@ class HomeViewModelTest { viewModel.uninstallApp(packageName) assertTrue(intentManager.wasUninstallApkCalled) } + + @Test + fun `installHomeApk delegates to the PackageInstaller coordinator`() { + val apk = createTestApkUiModel( + createTestParsedNightlyApk(testFenixAppName, testDateRaw, testVersion, testAbi), + ) + + viewModel.installHomeApk(apk) + + verify(installCoordinator).install(eq(apk.uniqueKey), eq(File(apk.apkDir, apk.fileName)), isNull()) + } + + @Test + fun `installApk delegates downloaded files to the PackageInstaller coordinator`() { + val apk = File(tempCacheDir, "downloaded.apk") + + viewModel.installApk(apk) + + verify(installCoordinator).install(eq(apk.absolutePath), eq(apk), isNull()) + } } diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/JobIconNameTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobIconNameTest.kt new file mode 100644 index 0000000..3329093 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobIconNameTest.kt @@ -0,0 +1,31 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.util.FENIX +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_NIGHTLY +import org.mozilla.tryfox.util.FENIX_RELEASE +import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.FOCUS_NIGHTLY + +class JobIconNameTest { + + @Test + fun `selects the requested app icon from the job name`() { + assertEquals(FOCUS, appIconNameForJob("Focus x86_64 build", "unknown")) + assertEquals(FENIX, appIconNameForJob("fenix-debug arm64-v8a", "unknown")) + assertEquals(FENIX_NIGHTLY, appIconNameForJob("fenix-nightly arm64-v8a", "unknown")) + assertEquals(FENIX_RELEASE, appIconNameForJob("fenix-release arm64-v8a", "unknown")) + assertEquals(FENIX_BETA, appIconNameForJob("fenix-beta arm64-v8a", "unknown")) + assertEquals(FOCUS, appIconNameForJob("focus-debug arm64-v8a", "unknown")) + assertEquals(FOCUS_NIGHTLY, appIconNameForJob("focus-nightly arm64-v8a", "unknown")) + assertEquals(FOCUS_BETA, appIconNameForJob("focus-beta arm64-v8a", "unknown")) + } + + @Test + fun `uses the job app name when no requested marker is present`() { + assertEquals("fenix", appIconNameForJob("Build Fenix for arm64-v8a", "fenix")) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt new file mode 100644 index 0000000..bed79c3 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/JobNameFormatterTest.kt @@ -0,0 +1,49 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class JobNameFormatterTest { + + @Test + fun `formats every signing APK app and channel`() { + val cases = mapOf( + "signing-apk-fenix-debug" to "Fenix debug", + "signing-apk-fenix-nightly" to "Fenix nightly", + "signing-apk-fenix-beta" to "Fenix beta", + "signing-apk-fenix-release" to "Fenix release", + "signing-apk-focus-debug" to "Focus debug", + "signing-apk-focus-nightly" to "Focus nightly", + "signing-apk-focus-beta" to "Focus beta", + "signing-apk-focus-release" to "Focus release", + ) + + cases.forEach { (jobName, expectedDisplayName) -> + assertEquals(expectedDisplayName, formatJobNameForDisplay(jobName)) + } + } + + @Test + fun `formats Firebase signing APK jobs`() { + assertEquals("Fenix nightly (firebase)", formatJobNameForDisplay("signing-apk-fenix-nightly-firebase")) + assertEquals("Focus beta (firebase)", formatJobNameForDisplay("signing-apk-focus-beta-firebase")) + } + + @Test + fun `formats simulation signing APK jobs as perftests`() { + assertEquals("Fenix nightly (perftests)", formatJobNameForDisplay("signing-apk-fenix-nightly-simulation")) + assertEquals("Focus beta (perftests)", formatJobNameForDisplay("SIGNING-APK-FOCUS-BETA-SIMULATION")) + } + + @Test + fun `preserves job names outside the signing APK naming convention`() { + assertEquals("Build Fenix for arm64-v8a", formatJobNameForDisplay("Build Fenix for arm64-v8a")) + assertEquals("signing-apk-fenix-esr", formatJobNameForDisplay("signing-apk-fenix-esr")) + assertEquals("signing-apk-fenix-nightly-firebase-extra", formatJobNameForDisplay("signing-apk-fenix-nightly-firebase-extra")) + } + + @Test + fun `formats signing APK job names without case sensitivity`() { + assertEquals("Focus release", formatJobNameForDisplay("SIGNING-APK-FOCUS-RELEASE")) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt new file mode 100644 index 0000000..c246384 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/NightlyDateFormatterTest.kt @@ -0,0 +1,60 @@ +package org.mozilla.tryfox.ui.screens + +import kotlinx.datetime.Instant +import kotlinx.datetime.LocalDate +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class NightlyDateFormatterTest { + + private val today = LocalDate(2026, 8, 5) + + @Test + fun `formats a build from today by replacing only its date`() { + assertEquals( + "Today 09:17", + "2026-08-05-09-17-32".formatNightlyBuildDate(today), + ) + } + + @Test + fun `formats a build from yesterday as Yesterday across a month boundary`() { + assertEquals( + "Yesterday 09:17", + "2026-07-31-09-17-32".formatNightlyBuildDate(LocalDate(2026, 8, 1)), + ) + } + + @Test + fun `formats an older build with its normal timestamp`() { + assertEquals( + "2026-08-03 09:17", + "2026-08-03-09-17-32".formatNightlyBuildDate(today), + ) + } + + @Test + fun `preserves an unrecognised date`() { + assertEquals( + "unknown-date", + "unknown-date".formatNightlyBuildDate(today), + ) + } + + @Test + fun `parses a build date independently of its display label`() { + assertEquals(today, "2026-08-05-09-17-32".rawNightlyBuildDate()) + assertEquals(LocalDate(2026, 8, 4), "2026-08-04-09-17-32".rawNightlyBuildDate()) + } + + @Test + fun `uses UTC midnight for the calendar selected date`() { + val selectedDate = LocalDate(2026, 8, 5) + + assertEquals( + Instant.parse("2026-08-05T00:00:00Z").toEpochMilliseconds(), + selectedDate.toDatePickerSelectionMillis(), + ) + assertEquals(selectedDate, datePickerSelectionDate(selectedDate.toDatePickerSelectionMillis())) + } +} 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 deleted file mode 100644 index 4ba91b3..0000000 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/ProfileViewModelTest.kt +++ /dev/null @@ -1,87 +0,0 @@ -package org.mozilla.tryfox.ui.screens - -import app.cash.turbine.test -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.test.runTest -import org.junit.jupiter.api.AfterEach -import org.junit.jupiter.api.Assertions.assertEquals -import org.junit.jupiter.api.BeforeEach -import org.junit.jupiter.api.Test -import org.junit.jupiter.api.extension.ExtendWith -import org.junit.jupiter.api.io.TempDir -import org.mockito.Mock -import org.mockito.junit.jupiter.MockitoExtension -import org.mozilla.tryfox.data.FakeDownloadFileRepository -import org.mozilla.tryfox.data.FakeHistoryRepository -import org.mozilla.tryfox.data.managers.FakeCacheManager -import org.mozilla.tryfox.data.managers.FakeIntentManager -import org.mozilla.tryfox.data.managers.FakeUserDataRepository -import org.mozilla.tryfox.data.repositories.TreeherderRepository -import java.io.File - -@ExperimentalCoroutinesApi -@ExtendWith(MockitoExtension::class) -class ProfileViewModelTest { - - private lateinit var viewModel: ProfileViewModel - private lateinit var cacheManager: FakeCacheManager - - @Mock - private lateinit var fenixRepository: TreeherderRepository - - private val userDataRepository = FakeUserDataRepository() - - private val downloadFileRepository = FakeDownloadFileRepository() - - private val intentManager = FakeIntentManager() - - private val historyRepository = FakeHistoryRepository() - - @TempDir - lateinit var tempCacheDir: File - - @BeforeEach - fun setUp() = runTest { - cacheManager = FakeCacheManager(tempCacheDir) - - viewModel = ProfileViewModel( - fenixRepository = fenixRepository, - downloadFileRepository = downloadFileRepository, - userDataRepository = userDataRepository, - cacheManager = cacheManager, - intentManager = intentManager, - historyRepository = historyRepository, - authorEmail = null, - ) - } - - @AfterEach - fun tearDown() { - cacheManager.reset() - } - - @Test - fun `updateAuthorEmail should update the authorEmail state`() = runTest { - // Given - val viewModel = ProfileViewModel( - fenixRepository, - downloadFileRepository, - userDataRepository, - cacheManager, - intentManager, - historyRepository, - null, - ) - val newEmail = "test@example.com" - - viewModel.authorEmail.test { - assertEquals("", awaitItem()) // Consume initial value - - // When - viewModel.updateAuthorEmail(newEmail) - - // Then - assertEquals(newEmail, awaitItem()) - } - } -} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/PushCommentSelectionTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/PushCommentSelectionTest.kt new file mode 100644 index 0000000..b57671c --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/PushCommentSelectionTest.kt @@ -0,0 +1,88 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.data.RevisionDetail + +class PushCommentSelectionTest { + + @Test + fun `uses the first real commit after a fuzzy Try trigger`() { + assertEquals( + "Bug 123 - Make the search result title useful", + selectPreferredPushComment( + revisions = listOf( + revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`"), + revision("Bug 123 - Make the search result title useful"), + ), + ), + ) + } + + @Test + fun `uses the first real commit even when it does not start with Bug`() { + assertEquals( + "Update Fenix dependencies", + selectPreferredPushComment( + revisions = listOf( + revision("Pushed via `mach try chooser`"), + revision("Update Fenix dependencies"), + revision("Bug 456 - A later commit"), + ), + ), + ) + } + + @Test + fun `keeps a Try trigger title when no real commit is available`() { + val triggerComment = "Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`" + + assertEquals( + triggerComment, + selectPreferredPushComment(revisions = listOf(revision(triggerComment))), + ) + } + + @Test + fun `uses the preceding real commit when a fuzzy push contains no real commit`() { + assertEquals( + "Update Fenix dependencies", + selectPreferredPushComment( + revisions = listOf(revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`")), + precedingPushRevisions = listOf( + listOf( + revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`"), + revision("Update Fenix dependencies"), + ), + ), + ), + ) + } + + @Test + fun `identifies trigger-only pushes that need a preceding commit`() { + assertTrue( + needsPrecedingRealCommit( + listOf(revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`")), + ), + ) + assertFalse( + needsPrecedingRealCommit( + listOf( + revision("Fuzzy query='build-apk-fenix-debug'\n\nPushed via `mach try fuzzy`"), + revision("Update Fenix dependencies"), + ), + ), + ) + } + + private fun revision(comments: String) = RevisionDetail( + resultSetId = 1, + repositoryId = 4, + revision = "abc123", + author = "tcampbell@mozilla.com", + comments = comments, + ) +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/PushTimeFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/PushTimeFormatterTest.kt new file mode 100644 index 0000000..cce9dc8 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/PushTimeFormatterTest.kt @@ -0,0 +1,53 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import java.time.Instant +import java.time.ZoneId +import java.util.Locale + +class PushTimeFormatterTest { + + private val zoneId = ZoneId.of("UTC") + private val locale = Locale.US + private val nowMillis = Instant.parse("2026-01-03T12:00:00Z").toEpochMilli() + + @Test + fun `formats a push from today with a relative label`() { + assertEquals( + "Today at 09:05", + formatRelativePushTime( + pushTimestampSeconds = Instant.parse("2026-01-03T09:05:45Z").epochSecond, + nowMillis = nowMillis, + zoneId = zoneId, + locale = locale, + ), + ) + } + + @Test + fun `formats a push from yesterday with a relative label`() { + assertEquals( + "Yesterday at 09:05", + formatRelativePushTime( + pushTimestampSeconds = Instant.parse("2026-01-02T09:05:45Z").epochSecond, + nowMillis = nowMillis, + zoneId = zoneId, + locale = locale, + ), + ) + } + + @Test + fun `formats older pushes with a date`() { + assertEquals( + "Jan 1 at 09:05", + formatRelativePushTime( + pushTimestampSeconds = Instant.parse("2026-01-01T09:05:45Z").epochSecond, + nowMillis = nowMillis, + zoneId = zoneId, + locale = locale, + ), + ) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt new file mode 100644 index 0000000..76b157d --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchQueryClassifierTest.kt @@ -0,0 +1,23 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class SearchQueryClassifierTest { + @Test fun `classifies trimmed email`() { + assertEquals(SearchQuery.Email("person+try@mozilla.org"), SearchQueryClassifier.classify(" person+try@mozilla.org ").getOrThrow()) + } + + @Test fun `classifies revision`() { + assertEquals(SearchQuery.Revision("abc123"), SearchQueryClassifier.classify(" abc123 ").getOrThrow()) + } + + @Test fun `classifies a blank query as recent pushes`() { + assertEquals(SearchQuery.RecentPushes, SearchQueryClassifier.classify(" ").getOrThrow()) + } + + @Test fun `rejects malformed email`() { + assertTrue(SearchQueryClassifier.classify("person@mozilla").isFailure) + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt new file mode 100644 index 0000000..78b2d48 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SearchViewModelTest.kt @@ -0,0 +1,363 @@ +package org.mozilla.tryfox.ui.screens + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.api.io.TempDir +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.eq +import org.mockito.kotlin.mock +import org.mockito.kotlin.never +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.mozilla.tryfox.data.Artifact +import org.mozilla.tryfox.data.ArtifactsResponse +import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.data.FakeHistoryRepository +import org.mozilla.tryfox.data.JobDetails +import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.RevisionDetail +import org.mozilla.tryfox.data.RevisionMeta +import org.mozilla.tryfox.data.RevisionResult +import org.mozilla.tryfox.data.TreeherderJobsResponse +import org.mozilla.tryfox.data.TreeherderRevisionResponse +import org.mozilla.tryfox.data.managers.FakeCacheManager +import org.mozilla.tryfox.data.managers.FakeUserDataRepository +import org.mozilla.tryfox.data.repositories.TreeherderRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.install.ApkInstallCoordinator +import org.mozilla.tryfox.install.TryBuildProvenance +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class SearchViewModelTest { + + @JvmField + @RegisterExtension + val mainCoroutineRule = MainCoroutineRule() + + @TempDir + lateinit var cacheDir: File + + @Test + fun `install uses the project retained by the loaded result`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getPushByRevision("mozilla-central", "abcdef123456")) + .thenReturn(NetworkResult.Success(revisionResponse())) + whenever(repository.getJobsForPush(1)).thenReturn( + NetworkResult.Success( + TreeherderJobsResponse( + listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")), + ), + ), + ) + whenever(repository.getArtifactsForTask("task-id")).thenReturn( + NetworkResult.Success( + ArtifactsResponse( + listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "", "application/vnd.android.package-archive")), + ), + ), + ) + val historyRepository = FakeHistoryRepository() + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = historyRepository, + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "abcdef123456", + project = "mozilla-central", + ) + + viewModel.submitSearch() + advanceUntilIdle() + val artifact = viewModel.pushes.value.single().jobs.single().artifacts.single() + val downloadedFile = File(cacheDir, "fenix-debug.apk") + viewModel.updateSelectedProject("try") + viewModel.downloadArtifact(artifact) + advanceUntilIdle() + + assertEquals("mozilla-central", historyRepository.recordedEntries.single().project) + + artifact.downloadState = DownloadState.Downloaded(downloadedFile) + + viewModel.installArtifact(artifact) + + val provenance = argumentCaptor() + verify(installCoordinator).install(eq(artifact.uniqueKey), eq(downloadedFile), provenance.capture()) + assertEquals("mozilla-central", provenance.firstValue.project) + } + + @Test + fun `author pagination uses an inclusive timestamp cursor`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getPushesByAuthor("try", "developer@example.com", 20, 0, null)) + .thenReturn(NetworkResult.Success(authorResponse((1..20).toList()))) + whenever(repository.getPushesByAuthor("try", "developer@example.com", 21, 0, 20)) + .thenReturn(NetworkResult.Success(authorResponse((20..40).toList()))) + whenever(repository.getJobsForPush(1)).thenReturn( + NetworkResult.Success( + TreeherderJobsResponse( + listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")), + ), + ), + ) + whenever(repository.getArtifactsForTask("task-id")).thenReturn( + NetworkResult.Success( + ArtifactsResponse( + listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "", "application/vnd.android.package-archive")), + ), + ), + ) + whenever(repository.getJobsForPush(21)).thenReturn( + NetworkResult.Success( + TreeherderJobsResponse( + listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")), + ), + ), + ) + for (id in 2..20) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } + for (id in 22..40) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "developer@example.com", + ) + + viewModel.searchByAuthor() + advanceUntilIdle() + viewModel.loadMorePushes() + advanceUntilIdle() + + assertEquals(listOf("revision-1", "revision-21"), viewModel.pushes.value.map { it.revision }) + verify(repository).getPushesByAuthor("try", "developer@example.com", 20, 0, null) + verify(repository).getPushesByAuthor("try", "developer@example.com", 21, 0, 20) + } + + @Test + fun `blank query checks the next page when the newest page has no APK`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getRecentPushes("try", 20, 0)) + .thenReturn(NetworkResult.Success(recentResponse((1..20).toList()))) + whenever(repository.getRecentPushes("try", 50, 20)) + .thenReturn(NetworkResult.Success(recentResponse((21..70).toList()))) + for (id in 1..20) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(noApkJobs())) + } + whenever(repository.getJobsForPush(21)).thenReturn( + NetworkResult.Success(TreeherderJobsResponse(listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")))), + ) + whenever(repository.getArtifactsForTask("task-id")).thenReturn( + NetworkResult.Success(ArtifactsResponse(listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "", "application/vnd.android.package-archive")))), + ) + for (id in 22..70) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(noApkJobs())) + } + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "", + ) + + viewModel.submitSearch() + advanceUntilIdle() + + assertEquals(listOf("revision-21"), viewModel.pushes.value.map { it.revision }) + verify(repository).getRecentPushes("try", 20, 0) + verify(repository).getRecentPushes("try", 50, 20) + } + + @Test + fun `failed empty-page fallback leaves load more available for a new attempt`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getRecentPushes("try", 20, 0)) + .thenReturn(NetworkResult.Success(recentResponse((1..20).toList()))) + whenever(repository.getRecentPushes("try", 20, 20)) + .thenReturn(NetworkResult.Success(recentResponse((21..40).toList()))) + whenever(repository.getRecentPushes("try", 50, 40)) + .thenReturn(NetworkResult.Error("fallback failed")) + whenever(repository.getRecentPushes("try", 20, 40)) + .thenReturn(NetworkResult.Success(recentResponse((41..60).toList()))) + whenever(repository.getRecentPushes("try", 50, 60)) + .thenReturn(NetworkResult.Error("fallback failed again")) + whenever(repository.getJobsForPush(1)).thenReturn( + NetworkResult.Success(TreeherderJobsResponse(listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "task-id")))), + ) + whenever(repository.getArtifactsForTask("task-id")).thenReturn( + NetworkResult.Success(ArtifactsResponse(listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "", "application/vnd.android.package-archive")))), + ) + for (id in 2..60) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(noApkJobs())) + } + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "", + ) + + viewModel.submitSearch() + advanceUntilIdle() + viewModel.loadMorePushes() + advanceUntilIdle() + + assertTrue(viewModel.canLoadMore.value) + assertEquals(null, viewModel.loadMoreError.value) + verify(repository, never()).getRecentPushes("try", 20, 40) + + viewModel.loadMorePushes() + advanceUntilIdle() + + verify(repository).getRecentPushes("try", 20, 40) + verify(repository).getRecentPushes("try", 50, 60) + } + + @Test + fun `empty job results end pagination and show an expiry warning`() = runTest { + val repository = mock() + val installCoordinator = mock() + whenever(installCoordinator.states).thenReturn(MutableStateFlow(emptyMap())) + whenever(installCoordinator.successfulInstalls).thenReturn(MutableSharedFlow()) + whenever(repository.getRecentPushes("try", 20, 0)) + .thenReturn(NetworkResult.Success(recentResponse((1..20).toList()))) + whenever(repository.getRecentPushes("try", 20, 20)) + .thenReturn(NetworkResult.Success(recentResponse((21..40).toList()))) + whenever(repository.getJobsForPush(1)).thenReturn( + NetworkResult.Success(TreeherderJobsResponse(listOf(JobDetails("fenix", "build-android-fenix-apk", "Bsign", "available-task")))), + ) + whenever(repository.getArtifactsForTask("available-task")).thenReturn( + NetworkResult.Success(ArtifactsResponse(listOf(Artifact("s3", "public/build/target.arm64-v8a.apk", "2027-01-01T00:00:00Z", "application/vnd.android.package-archive")))), + ) + whenever(repository.getJobsForPush(21)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + for (id in 2..20) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } + for (id in 22..40) { + whenever(repository.getJobsForPush(id)).thenReturn(NetworkResult.Success(TreeherderJobsResponse(emptyList()))) + } + val viewModel = SearchViewModel( + fenixRepository = repository, + userDataRepository = FakeUserDataRepository(), + cacheManager = FakeCacheManager(cacheDir), + historyRepository = FakeHistoryRepository(), + downloadCoordinator = FakeDownloadCoordinator(), + installCoordinator = installCoordinator, + authorEmail = "", + ) + + viewModel.submitSearch() + advanceUntilIdle() + assertTrue(viewModel.canLoadMore.value) + + viewModel.loadMorePushes() + advanceUntilIdle() + + assertEquals(listOf("revision-1"), viewModel.pushes.value.map { it.revision }) + assertFalse(viewModel.canLoadMore.value) + assertEquals("Older pushes' jobs have expired.", viewModel.warningMessage.value) + } + + private fun revisionResponse() = TreeherderRevisionResponse( + meta = RevisionMeta(revision = "abcdef123456", count = 1, repository = "mozilla-central"), + results = listOf( + RevisionResult( + id = 1, + revision = "abcdef123456", + author = "developer@example.com", + revisions = listOf( + RevisionDetail(1, 1, "abcdef123456", "developer@example.com", "Fix Fenix Debug"), + ), + revisionCount = 1, + pushTimestamp = 1, + repositoryId = 1, + ), + ), + ) + + private fun authorResponse(ids: List) = TreeherderRevisionResponse( + meta = RevisionMeta(revision = null, count = ids.size, repository = "try"), + results = ids.map { id -> + RevisionResult( + id = id, + revision = "revision-$id", + author = "developer@example.com", + revisions = listOf( + RevisionDetail(id, 1, "revision-$id", "developer@example.com", "Push $id"), + ), + revisionCount = 1, + pushTimestamp = id.toLong(), + repositoryId = 1, + ) + }, + ) + + private fun recentResponse(ids: List) = TreeherderRevisionResponse( + meta = RevisionMeta(revision = null, count = ids.size, repository = "try"), + results = ids.map { id -> + RevisionResult( + id = id, + revision = "revision-$id", + author = "developer@example.com", + revisions = listOf( + RevisionDetail(id, 1, "revision-$id", "developer@example.com", "Push $id"), + ), + revisionCount = 1, + pushTimestamp = id.toLong(), + repositoryId = 1, + ) + }, + ) + + private fun noApkJobs() = TreeherderJobsResponse( + listOf(JobDetails("fenix", "build-android-fenix", "B", "no-apk-task")), + ) + + private class FakeDownloadCoordinator : ApkDownloadCoordinator { + override val downloads = MutableStateFlow>(emptyMap()) + + override fun enqueue(request: org.mozilla.tryfox.download.ApkDownloadRequest) = "work-id" + + override fun retry(request: org.mozilla.tryfox.download.ApkDownloadRequest) = "work-id" + + override fun cancel(uniqueKey: String) = Unit + + override fun observe(uniqueKey: String) = emptyFlow() + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt new file mode 100644 index 0000000..f9294b9 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt @@ -0,0 +1,90 @@ +package org.mozilla.tryfox.ui.screens + +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.extension.RegisterExtension +import org.junit.jupiter.api.io.TempDir +import org.mozilla.tryfox.data.managers.FakeCacheManager +import org.mozilla.tryfox.data.managers.FakeUserDataRepository +import org.mozilla.tryfox.download.ApkDownloadCoordinator +import org.mozilla.tryfox.download.ApkDownloadRequest +import org.mozilla.tryfox.download.model.DownloadStatus +import org.mozilla.tryfox.download.model.PersistedDownloadState +import org.mozilla.tryfox.model.HomeScreenLayout +import java.io.File + +@OptIn(ExperimentalCoroutinesApi::class) +class SettingsViewModelTest { + @JvmField + @RegisterExtension + val mainCoroutineRule = MainCoroutineRule() + + @TempDir + lateinit var tempDir: File + + @Test + fun `layout selection is persisted and cache clearing follows availability`() = runTest { + val cacheManager = FakeCacheManager(tempDir) + val downloads = FakeDownloadCoordinator() + val userData = FakeUserDataRepository() + val viewModel = SettingsViewModel(cacheManager, downloads, userData) + advanceUntilIdle() + + assertEquals(HomeScreenLayout.OneCardPerApp, viewModel.uiState.value.homeScreenLayout) + assertFalse(viewModel.uiState.value.canClearCache) + + cacheManager.setCacheSizeBytes(42) + advanceUntilIdle() + assertTrue(viewModel.uiState.value.canClearCache) + + viewModel.selectHomeScreenLayout(HomeScreenLayout.OneCardPerFlavor) + advanceUntilIdle() + assertEquals(HomeScreenLayout.OneCardPerFlavor, viewModel.uiState.value.homeScreenLayout) + + downloads.setActiveDownload() + advanceUntilIdle() + assertFalse(viewModel.uiState.value.canClearCache) + + downloads.clear() + advanceUntilIdle() + viewModel.clearCache() + advanceUntilIdle() + assertTrue(cacheManager.clearCacheCalled) + assertEquals(0L, viewModel.uiState.value.cacheSizeBytes) + } + + private class FakeDownloadCoordinator : ApkDownloadCoordinator { + private val states = MutableStateFlow>(emptyMap()) + override val downloads = states + + override fun enqueue(request: ApkDownloadRequest): String = request.uniqueKey + override fun retry(request: ApkDownloadRequest): String = request.uniqueKey + override fun cancel(uniqueKey: String) = Unit + override fun observe(uniqueKey: String): Flow = downloads.map { it[uniqueKey] } + + fun setActiveDownload() { + states.value = mapOf( + "download" to PersistedDownloadState( + uniqueKey = "download", + downloadUrl = "https://example.invalid/download.apk", + outputPath = "download.apk", + appName = "fenix", + fileName = "download.apk", + status = DownloadStatus.RUNNING, + ), + ) + } + + fun clear() { + states.value = emptyMap() + } + } +} diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/UnsignedApkFilterTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/UnsignedApkFilterTest.kt new file mode 100644 index 0000000..7e5e9c2 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/UnsignedApkFilterTest.kt @@ -0,0 +1,62 @@ +package org.mozilla.tryfox.ui.screens + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.ui.models.JobDetailsUiModel + +class UnsignedApkFilterTest { + + @Test + fun `hides an unsigned build when its signed equivalent is available`() { + val signed = job("signing-apk-fenix-nightly", signed = true) + val unsigned = job("build-apk-fenix-nightly", signed = false) + + assertEquals(listOf(signed), filterRedundantUnsignedApkJobs(listOf(signed, unsigned))) + } + + @Test + fun `keeps an unsigned build when its signed equivalent is unavailable`() { + val unsigned = job("build-apk-fenix-nightly", signed = false) + + assertEquals(listOf(unsigned), filterRedundantUnsignedApkJobs(listOf(unsigned))) + } + + @Test + fun `keeps builds with a different signing variant`() { + val signedBeta = job("signing-apk-fenix-beta", signed = true) + val unsignedNightly = job("build-apk-fenix-nightly", signed = false) + + assertEquals( + listOf(signedBeta, unsignedNightly), + filterRedundantUnsignedApkJobs(listOf(signedBeta, unsignedNightly)), + ) + } + + @Test + fun `does not treat an unsigned signing-named job as an available signed equivalent`() { + val signingNamedButUnsigned = job("signing-apk-fenix-nightly", signed = false) + val unsigned = job("build-apk-fenix-nightly", signed = false) + + assertEquals( + listOf(signingNamedButUnsigned, unsigned), + filterRedundantUnsignedApkJobs(listOf(signingNamedButUnsigned, unsigned)), + ) + } + + @Test + fun `matches job names ignoring case and surrounding whitespace`() { + val signed = job(" signing-apk-fenix-nightly ", signed = true) + val unsigned = job("BUILD-APK-FENIX-NIGHTLY", signed = false) + + assertEquals(listOf(signed), filterRedundantUnsignedApkJobs(listOf(signed, unsigned))) + } + + private fun job(jobName: String, signed: Boolean) = JobDetailsUiModel( + appName = "fenix", + jobName = jobName, + jobSymbol = "B", + taskId = jobName, + isSignedBuild = signed, + isTest = false, + ) +} diff --git a/app/src/test/java/org/mozilla/tryfox/util/CommitMessageFormatterTest.kt b/app/src/test/java/org/mozilla/tryfox/util/CommitMessageFormatterTest.kt new file mode 100644 index 0000000..a4e052d --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/util/CommitMessageFormatterTest.kt @@ -0,0 +1,51 @@ +package org.mozilla.tryfox.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test + +class CommitMessageFormatterTest { + @Test + fun `removes trailing reviewer group directive`() { + assertEquals( + "Bug 123: Update the feature", + "Bug 123: Update the feature r=#android-reviewers".withoutTrailingReviewerDirective(), + ) + } + + @Test + fun `removes trailing reviewer request directive`() { + assertEquals( + "Bug 123: Update the feature", + "Bug 123: Update the feature r?#android-reviewers".withoutTrailingReviewerDirective(), + ) + } + + @Test + fun `removes comma separated reviewers and approval marker`() { + assertEquals( + "Bug 123: Update the feature", + "Bug 123: Update the feature r=reviewer1, reviewer2!".withoutTrailingReviewerDirective(), + ) + } + + @Test + fun `preserves commit description after cleaning the subject`() { + assertEquals( + "Bug 123: Update the feature\n\nExplain why the change is needed.", + "Bug 123: Update the feature r=reviewer1\n\nExplain why the change is needed." + .withoutTrailingReviewerDirective(), + ) + } + + @Test + fun `leaves messages without a trailing reviewer directive unchanged`() { + assertEquals( + "Bug 123: Explain r=reviewer1 in the description", + "Bug 123: Explain r=reviewer1 in the description".withoutTrailingReviewerDirective(), + ) + assertEquals( + "Bug 123: Update the feature\nReviewer metadata: r=reviewer1", + "Bug 123: Update the feature\nReviewer metadata: r=reviewer1".withoutTrailingReviewerDirective(), + ) + } +} diff --git a/assets/tryfox-icon-black.png b/assets/tryfox-icon-black.png new file mode 100644 index 0000000..71beec9 Binary files /dev/null and b/assets/tryfox-icon-black.png differ diff --git a/doc/imported-app-icons.md b/doc/imported-app-icons.md new file mode 100644 index 0000000..1d06308 --- /dev/null +++ b/doc/imported-app-icons.md @@ -0,0 +1,14 @@ +# Imported app icon sources + +The following launcher-icon assets were copied into TryFox from the local Firefox Android checkout. + +| TryFox resource | Source path | +| --- | --- | +| `ic_fenix_debug_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/fenix/app/src/debug/res/drawable/ic_launcher_foreground.xml` | +| `ic_fenix_nightly_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/fenix/app/src/nightly/res/drawable/ic_launcher_foreground.xml` | +| `ic_fenix_beta_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/fenix/app/src/beta/res/drawable/ic_launcher_foreground.xml` | +| `ic_focus_debug_foreground_v2.png` | Derived from `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/debug/res/mipmap-xxxhdpi/ic_launcher_foreground.png` with an enlarged foreground and DEV lettering; the source banner height is preserved. | +| `ic_focus_nightly_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/nightly/res/drawable/ic_launcher_foreground.xml` | +| `ic_focus_beta_foreground.xml` | `/Users/titouan/Documents/Mozilla/Android/firefox/mobile/android/focus-android/app/src/focusBeta/res/drawable-v24/ic_launcher_foreground.xml` | + +Focus Debug uses its copied transparent foreground directly. diff --git a/doc/unified-search-screen.html b/doc/unified-search-screen.html new file mode 100644 index 0000000..50e876b --- /dev/null +++ b/doc/unified-search-screen.html @@ -0,0 +1,216 @@ + + + + + + TryFox unified search mockup + + + +
+

Search builds

+
+
+
+ + +
+ +
+ +
+ + +
+

Searching by email

+
+
+ +
+

2 pushes found

+ +
+

Bug 2000991 — Refresh startup profile

+ + +
+
+
+
+ + + + diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index eed9f72..21f720b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,6 +31,7 @@ camerax = "1.5.3" mlkitBarcodeScanning = "17.3.0" ktor = "3.5.0" zxing = "3.5.3" +androidxWork = "2.9.1" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -81,6 +82,7 @@ ktor-server-core = { group = "io.ktor", name = "ktor-server-core", version.ref = ktor-server-content-negotiation = { group = "io.ktor", name = "ktor-server-content-negotiation", version.ref = "ktor" } ktor-serialization-kotlinx-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } zxing-core = { group = "com.google.zxing", name = "core", version.ref = "zxing" } +androidx-work-runtime-ktx = { group = "androidx.work", name = "work-runtime-ktx", version.ref = "androidxWork" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } diff --git a/plans/tryfox_mockup.html b/plans/tryfox_mockup.html new file mode 100644 index 0000000..2e4d6d3 --- /dev/null +++ b/plans/tryfox_mockup.html @@ -0,0 +1,763 @@ + + + + + + TryFox — Android mockup + + + +
+
+
+
+

WELCOME TO

+

TryFox

+
+ +
+

Download and try Firefox-based browsers

+
+ +
+
+ + +
+

NIGHTLY BUILD

+

Choose a build date

+ +
+ + +
+
+
+ + + + + + diff --git a/plans/workmanager-apk-download-plan.md b/plans/workmanager-apk-download-plan.md new file mode 100644 index 0000000..f314b3b --- /dev/null +++ b/plans/workmanager-apk-download-plan.md @@ -0,0 +1,232 @@ +# WorkManager APK Download Plan + +## Goal + +Move APK downloads out of `viewModelScope` and into a `WorkManager`-backed pipeline so downloads can continue when the app is backgrounded or the process is recreated, while keeping TryFox's existing internal cache model. + +## Key Decisions + +- Keep the APK cache in internal `filesDir/download-cache`. +- Use `WorkManager` rather than `DownloadManager`. +- Reuse the existing `DownloadFileRepository` for transfer logic. +- Persist download state so the UI can reconnect after process death. +- Treat installation as an explicit user action after download completion. + +## Phase 1: Add Download Domain Owned By WorkManager + +Create a dedicated download layer instead of pushing worker logic into the existing view models. + +Files to add: + +- `app/src/main/java/org/mozilla/tryfox/download/ApkDownloadWorker.kt` +- `app/src/main/java/org/mozilla/tryfox/download/ApkDownloadCoordinator.kt` +- `app/src/main/java/org/mozilla/tryfox/download/ApkDownloadStore.kt` +- `app/src/main/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinator.kt` +- `app/src/main/java/org/mozilla/tryfox/download/model/PersistedDownloadState.kt` + +Responsibilities: + +- `ApkDownloadWorker`: execute one APK download, run as foreground work, report progress. +- `ApkDownloadCoordinator`: app-facing API for `enqueue`, `cancel`, `observe`, `retry`. +- `ApkDownloadStore`: persistent state for reconnecting UI after process death. +- `PersistedDownloadState`: queued, running, succeeded, failed, canceled plus progress metadata. + +Use the existing `DownloadFileRepository` instead of replacing it. + +## Phase 2: Wire Persistence For Reconnectable State + +Add a persistent store before moving UI off `viewModelScope`. + +Preferred approach: + +- Add a small SQLite table beside the existing history DB. + +Reasoning: + +- The repo already uses SQLite in `DefaultHistoryRepository`. +- This keeps query semantics straightforward. +- It avoids pushing restart-critical state into an in-memory-only model. + +Suggested fields: + +- `unique_key` +- `download_url` +- `cache_relative_path` +- `app_name` +- `file_name` +- `status` +- `bytes_downloaded` +- `total_bytes` +- `error_message` +- `work_id` +- `created_at` +- `updated_at` + +This store should be the source of truth for logical download state, while file existence remains the source of truth for whether an APK is actually downloaded. + +## Phase 3: Add WorkManager And DI Integration + +Update build and app wiring. + +Files to change: + +- `app/build.gradle.kts` +- `app/src/main/java/org/mozilla/tryfox/TryFoxApplication.kt` +- `app/src/main/java/org/mozilla/tryfox/di/AppModule.kt` + +Changes: + +- Add `androidx.work:work-runtime-ktx`. +- Register `WorkManager` access in DI. +- Register the new coordinator and store. +- Add a custom `WorkerFactory` if needed so the worker can receive `DownloadFileRepository`, `CacheManager`, and the store via DI. + +At this stage, do not touch the UI yet. Only make it possible to enqueue a worker end-to-end. + +## Phase 4: Implement Foreground Download Worker + +`ApkDownloadWorker` should: + +- Resolve the output file from `cacheRelativePath` and the current cache root. +- Call `setForeground()` immediately. +- Reuse `DefaultDownloadFileRepository`. +- Update both `setProgress(...)` and `ApkDownloadStore` during transfer. +- Mark the final state in the store on success, failure, or cancellation. +- Call `cacheManager.checkCacheStatus()` on completion. +- Post a completion notification when the download succeeds. + +Important detail: + +- Do not attempt background auto-install. +- Completion should produce a notification or UI state that lets the user install explicitly. + +## Phase 5: Move One Screen First, Then The Other + +Start with history. It already has more explicit lifecycle and cancellation semantics than home. + +### History flow + +Refactor `HistoryViewModel` to replace: + +- direct `viewModelScope.launch(ioDispatcher)` download execution +- `activeDownloads` +- `canceledDownloads` +- most in-memory progress bookkeeping + +With: + +- coordinator `enqueueDownload(entry)` +- coordinator `cancelDownload(uniqueKey)` +- derived UI state from: + - history entries + - persisted download states + - file existence + +Keep: + +- delete and history cleanup logic +- cache resolution logic around `cacheRelativePath` + +### Home flow + +Refactor `HomeViewModel` to replace: + +- `downloadNightlyApk()` direct repository call +- in-memory `InProgress` updates + +With: + +- coordinator start +- observed progress mapped by `apkInfo.uniqueKey` + +### Install flow + +Keep `IntentManager` unchanged. + +- Installation should happen when the user taps a downloaded item. +- The completion notification can also deep link back into the app for install. + +## Notification Work + +Add a small notification helper. + +Files to add: + +- `app/src/main/java/org/mozilla/tryfox/download/DownloadNotificationFactory.kt` + +Files to update: + +- `app/src/main/AndroidManifest.xml` +- `app/src/main/res/values/strings.xml` + +Needed behavior: + +- progress notification while worker is active +- completion notification with pending intent back into app +- optional failure notification with retry action + +## State Model Changes + +Relevant files: + +- `app/src/main/java/org/mozilla/tryfox/data/DownloadState.kt` +- `app/src/main/java/org/mozilla/tryfox/ui/models/HistoryItemUiModel.kt` +- `app/src/main/java/org/mozilla/tryfox/ui/models/ApkUiModel.kt` + +Approach: + +- Keep `DownloadState` as the UI contract. +- Add a mapper from persisted worker or store state to `DownloadState`. +- Let file existence win when a file is actually present. + +This avoids unnecessary UI model churn. + +## Testing Plan + +Unit tests to add: + +- worker success, failure, and cancellation behavior +- coordinator deduplication and cancellation +- state mapping from persisted store to `DownloadState` + +Existing tests to extend: + +- `app/src/test/java/org/mozilla/tryfox/data/repositories/DefaultDownloadFileRepositoryTest.kt` +- `app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt` +- `app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt` + +New tests to add: + +- `app/src/test/java/org/mozilla/tryfox/download/ApkDownloadWorkerTest.kt` +- `app/src/test/java/org/mozilla/tryfox/download/DefaultApkDownloadCoordinatorTest.kt` + +Instrumentation tests later: + +- start download, background app, resume app, state reconnects +- completion notification leads to install path + +## Recommended Implementation Order + +1. Add WorkManager dependency and DI scaffolding. +2. Add persistent download store. +3. Implement `ApkDownloadWorker`. +4. Implement coordinator and unique work policies. +5. Move `HistoryViewModel`. +6. Add foreground and completion notifications. +7. Move `HomeViewModel`. +8. Remove obsolete in-memory download lifecycle code. + +## Policies To Lock Before Coding + +- Unique work name: use the current `uniqueKey`. +- Cache root: keep internal `filesDir/download-cache`. +- Success UX: downloaded and ready to install, not auto-install in background. +- Retry policy: manual retry first, automatic retry only for clearly transient network failures. +- Cancellation: cancel worker and delete `.part` and managed temp files, but never delete a completed APK. + +## Main Risks + +- Duplicate state sources if `DownloadState`, file existence, and worker progress are not unified carefully. +- Race conditions around cancel versus complete. +- Worker DI and test setup friction. +- UX inconsistency if some flows still auto-install while others move to explicit install. diff --git a/unified-search-screen-plan.md b/unified-search-screen-plan.md new file mode 100644 index 0000000..583355b --- /dev/null +++ b/unified-search-screen-plan.md @@ -0,0 +1,115 @@ +# Unified search screen implementation plan + +## Goal and scope + +Replace the separate Profile and Treeherder APK screens with one `Search builds` screen. It must let a user select a Treeherder project (default: `try`), enter either an author email address or a revision, choose the correct request from that input, and show downloadable APKs grouped under each matching push. + +The screen will show, for every result push: + +- the commit message; +- a formatted push time and author; +- one row for every compatible APK-producing job, prefixed by the existing app icon; and +- only the APK variant best suited to this device, with the existing download/install state and actions. + +The Profile destination and its Home-screen icon will be removed. The existing revision and author deep links will continue to open the new screen and immediately search. + +## Current implementation to consolidate + +- `TryFoxMainScreen` / `TryFoxViewModel` already own the project + revision search, revision push metadata, artifact fetching, cache state, downloads, history writes, and installs. +- `ProfileScreen` / `ProfileViewModel` separately implement author search, then repeat much of the artifact/download/history logic. +- `TreeherderApiService.getPushByAuthor` is hard-coded to `project/try/push/`; it needs to use the selected project, just as revision lookup does. +- `AppDeepLinkParser` already recognizes both revision links (`revision`) and author links (`author`). `AppDeepLinkRouteMapper` currently maps author links to the Profile route. + +## Implementation steps + +1. Introduce a unified search model and query classification. + + - Add a small, unit-testable query type in the screen package (for example `SearchQuery.Email` and `SearchQuery.Revision`) plus a classifier that trims the input. + - Classify a syntactically valid email address as `Email`; treat a non-empty value without an `@` as a revision. Treat malformed `@` input as invalid and expose a clear validation error instead of issuing either request. + - Keep `try` as the initial selected project. Retain project selection for both search types, because author lookup will become project-scoped. + - Represent UI state as a single screen state: selected project, query text/type, loading/error state, and a list of push results. Each push result contains its comment, author, timestamp, revision, and compatible job/APK rows. + +2. Consolidate the two ViewModels into one search ViewModel. + + - Create `UnifiedSearchViewModel` by extracting the reusable cache, artifact-fetching, download, install, and history code from the two existing ViewModels. Prefer moving shared behavior into private helpers or a dedicated search/result loader instead of copying one ViewModel into the other. + - On search, dispatch `TreeherderRepository.getPushByRevision(selectedProject, revision)` for revisions and `getPushesByAuthor(selectedProject, email)` for emails. Preserve the Profile screen's bounded author-result behavior (currently 10 pushes) unless product requirements change. + - Convert every returned push to the same result shape: derive its first Bug comment when present (otherwise the first revision comment), retain its author and push timestamp, then load signed Android APK jobs and artifacts for that push. + - Reuse the existing preferred-job/fallback-job selection and pagination behavior from `TryFoxViewModel`; apply it per returned push for author searches. Preserve partial-result error reporting if a later jobs page fails. + - During artifact mapping, select exactly one APK per job. Determine compatibility from the device ABI list and choose the compatible artifact matching the first ABI in `Build.SUPPORTED_ABIS` order; do not surface other compatible or incompatible variants. Jobs with no compatible APK are omitted from the displayed list. + - Preserve cache refresh, WorkManager download state observation, automatic install behavior, and history upsert/update behavior for the selected artifact. Keep cache clearing disabled while any selected artifact is downloading. + - Delete `ProfileViewModel` only after the unified ViewModel has equivalent author-search and download/install coverage. + +3. Replace the Compose UI with the approved layout. + + - Rename or replace `TryFoxMainScreen` / `TreeherderApksScreen.kt` with a unified `SearchScreen` and move generic composables out only where it reduces duplication. + - Keep the existing top app bar, back action, cache-clear action, and project dropdown. Default the dropdown to `try`. + - Replace the revision-only field with a single `Email or revision` field. It should visibly indicate the detected type (email or revision), search on the keyboard action and the search button, and be enabled only for non-blank valid input while not loading. + - Remove the `Find a push` and explanatory text from the search card. + - Render each push as one card with the commit message, a formatted timestamp (reuse the existing push-time formatting/chip utility where appropriate), and author. Beneath it, render one compact row per selected compatible job: `AppIcon`, the job name, and the existing `DownloadButton`. Do not show ABI chips, task IDs, unsupported variants, or a second variant chooser. + - Use stable keys based on the push identifier/revision and job task ID. Keep distinct empty, no-results, loading, and recoverable-error states for both query types. + - Update/add string resources for neutral search copy and result states; remove Profile-only strings only after no source/test uses remain. Update accessibility labels and Compose test tags to be query-neutral. + - Delete `ProfileScreen.kt` after migrating any useful private UI pieces (notably the compatible-APK row) to the unified screen. + +4. Make author lookup project-aware at the repository boundary. + + - Change `TreeherderApiService.getPushByAuthor` to use `@GET("project/{project}/push/")` and a `@Path project` argument with its existing `full`, `count`, and `author` query parameters. + - Change `TreeherderRepository.getPushesByAuthor` and `DefaultTreeherderRepository` to accept and forward `project`. + - Update every production fake and test implementation of `TreeherderRepository`. Add an assertion in the unified ViewModel tests that an email search forwards the selected project. + +5. Simplify navigation and preserve deep links. + + - Replace the Home screen's two callbacks (`onNavigateToTreeherder` and `onNavigateToProfile`) with one `onNavigateToSearch`; remove `AccountCircle`, the Profile icon button, and `home_profile_button_description`. Keep the Search icon, now described as the unified build search. + - Keep `treeherder_search` as the canonical destination and keep the existing `treeherder_search/{project}/{revision}` path shape. Rename its argument concept from `revision` to `query` internally, so the exact same route can preload either an email or a revision without changing existing revision route strings. + - Change `AppDeepLinkRouteMapper` so an author destination maps to `AppRoutes.createTreeherderSearchRoute(project = "try", query = email)`. Revision destinations keep their project and current route output. URL encoding remains required for emails and unusual revisions. + - Keep `AppDeepLinkParser` support for all existing accepted link forms: + - `https://treeherder.mozilla.org/jobs?repo=…&revision=…` + - `tryfox://jobs?repo=…&revision=…` + - the corresponding `author=…` forms, defaulting the project to `try` when no `repo` is present. + - Retain a temporary `profile_by_email?email=…` NavHost compatibility alias that opens `SearchScreen` with project `try` and the email prefilled/searched. It is not exposed in Home and can be removed in a later release after any persisted/internal route consumers have migrated. Remove the plain `profile` destination rather than retaining an empty screen. + - Update `HistoryScreen` and `ReceiveFromDesktopScreen` navigation callbacks to use the unified route helper; their revision deep links should continue to auto-search. + +6. Remove dead code and update DI. + + - Register `UnifiedSearchViewModel` in `AppModule` with the dependencies currently split between `TryFoxViewModel` and `ProfileViewModel`, including `UserDataRepository` only if the product still wants to remember the last email. If retained, load it only as a prefill; do not auto-search on a normal Home navigation. + - Remove the `ProfileViewModel` Koin registration, `ProfileScreen` import/route, and any no-longer-used Profile-only composables/models/strings. + - Rename Treeherder-only identifiers where that makes the unified responsibility clearer, while leaving storage/cache names and persisted history schema unchanged for compatibility. + - Do not change the Android intent filters: they already admit both Treeherder HTTPS and `tryfox://jobs` links. + +## Test plan + +### Unit tests + +- Add classifier tests for valid email, revision, whitespace trimming, blank input, and malformed input containing `@`. +- Replace/expand `ProfileViewModelTest` with `UnifiedSearchViewModelTest`: + - email search calls the author endpoint with the selected project and builds multiple push cards; + - revision search calls the revision endpoint with the selected project and builds one push card; + - each card retains the expected comment, author, and timestamp; + - jobs without a compatible APK are excluded; + - when multiple APK ABIs are available, the selected artifact follows the supplied device ABI preference order and only that artifact is exposed; + - signed-job preference, fallback, pagination, de-duplication, partial errors, cache/download state, history write, and install behavior continue to pass (migrate the relevant existing `TryFoxViewModelTest` and `ProfileViewModelTest` cases rather than losing them); + - an email/revision deep-link initialization triggers the correct request exactly once. +- Update repository/service tests and fakes for the new `getPushesByAuthor(project, author)` signature. +- Update `AppDeepLinkParserTest` to assert author links still parse, including percent-encoded addresses and default/missing repo behavior. +- Update `AppDeepLinkRouteMapperTest` to assert revision route outputs are unchanged and author links now encode to the unified Treeherder-search route. Add a route test for an email containing `+`. + +### Compose and instrumentation tests + +- Replace `ProfileScreenTest` and `TreeherderApksScreenTest` with `UnifiedSearchScreenTest` coverage for: + - `try` shown as the default project; + - typing an email/revision changes the detected search type and invokes the correct ViewModel action; + - the revision deep-link/loading state and result header still behave correctly; + - an email search renders multiple push cards with message, formatted time, author, app icon, job name, and one compatible APK action per job; + - unsupported and duplicate ABI variants are absent from the UI; + - download transitions from Download to Downloading to Install and triggers installation as before; + - no-results and invalid-query error states are accessible. +- Update `MainActivityDeeplinkTest` to cover opening both a revision URI and an author URI into the unified screen, including back-navigation behavior. Keep the existing QR-scanned deep-link path covered. +- Update Home screen tests (or add them if absent) to assert there is one unified Search action and no Profile action. + +### Verification commands + +1. `./gradlew :app:detekt` +2. `./gradlew :app:ktlintCheck` +3. `./gradlew :app:testDebugUnitTest` +4. `./gradlew :app:connectedDebugAndroidTest` on an emulator/device when available + +Review the mockup in `doc/unified-search-screen.html` alongside the final Compose screen; update the mockup only if the implementation reveals a needed design decision.