diff --git a/README.md b/README.md index 0eb7aed..0e4c6dc 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. 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/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/FakeUserDataRepository.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt index 22cc462..1d4f426 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt @@ -2,6 +2,9 @@ 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 @@ -12,11 +15,22 @@ 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 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) { @@ -26,5 +40,6 @@ class FakeUserDataRepository : UserDataRepository { // 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..4f3c3b7 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,8 +13,8 @@ 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 @@ -34,7 +34,7 @@ class HistoryScreenTest { } val historyViewModel = HistoryViewModel( historyRepository = historyRepository, - downloadFileRepository = FakeDownloadFileRepository(), + downloadCoordinator = FakeApkDownloadCoordinator(), cacheManager = FakeCacheManager(), intentManager = FakeIntentManager(), ) @@ -68,7 +68,7 @@ class HistoryScreenTest { } val historyViewModel = HistoryViewModel( historyRepository = historyRepository, - downloadFileRepository = FakeDownloadFileRepository(), + downloadCoordinator = FakeApkDownloadCoordinator(), cacheManager = FakeCacheManager(), intentManager = FakeIntentManager(), ) 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 61% 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..38d0b16 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,8 +10,11 @@ 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 @@ -24,13 +30,15 @@ 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.TreeherderRepository import org.mozilla.tryfox.ui.theme.TryFoxTheme @RunWith(AndroidJUnit4::class) -class TreeherderApksScreenTest { +class SearchScreenTest { @get:Rule val composeTestRule = createComposeRule() @@ -38,6 +46,7 @@ class TreeherderApksScreenTest { @Test fun treeherderScreen_showsLoaderUntilSearchCompletes_thenDisplaysResults() { val targetJobName = "signing-apk-focus-nightly" + val targetDisplayName = "Focus nightly" val viewModel = TryFoxViewModel( fenixRepository = DelayedTreeherderRepository(targetJobName = targetJobName), downloadFileRepository = FakeDownloadFileRepository(), @@ -52,7 +61,7 @@ class TreeherderApksScreenTest { composeTestRule.setContent { TryFoxTheme { - TryFoxMainScreen( + SearchScreen( tryFoxViewModel = viewModel, deepLinkProject = null, deepLinkRevision = null, @@ -68,22 +77,98 @@ 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 = TryFoxViewModel( + fenixRepository = DelayedTreeherderRepository(targetJobName = "signing-apk-focus-nightly"), + downloadFileRepository = FakeDownloadFileRepository(), + cacheManager = FakeCacheManager(), + intentManager = FakeIntentManager(), + historyRepository = FakeHistoryRepository(), + project = null, + revision = null, + supportedAbis = listOf("arm64-v8a"), + infoLogger = { _, _ -> 0 }, + ) + + composeTestRule.setContent { + TryFoxTheme { + SearchScreen( + tryFoxViewModel = viewModel, + deepLinkProject = null, + deepLinkRevision = 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 { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 9d7bbe1..2944161 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -7,6 +7,7 @@ + @@ -14,8 +15,10 @@ + + @@ -73,12 +76,8 @@ - - - - + 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..58b9de3 100644 --- a/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt +++ b/app/src/main/java/org/mozilla/tryfox/AppRoutes.kt @@ -9,16 +9,10 @@ object AppRoutes { 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..c884c5e 100644 --- a/app/src/main/java/org/mozilla/tryfox/MainActivity.kt +++ b/app/src/main/java/org/mozilla/tryfox/MainActivity.kt @@ -6,27 +6,39 @@ 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.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.theme.TryFoxTheme /** @@ -59,7 +71,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 +82,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 +92,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,9 +140,31 @@ 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) } @@ -132,8 +173,7 @@ class MainActivity : ComponentActivity() { 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) }, @@ -154,9 +194,7 @@ class MainActivity : ComponentActivity() { 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 +203,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 +223,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( + deepLinkQuery = query, 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( - onNavigateUp = { localNavController.popBackStack() }, - profileViewModel = koinViewModel { parametersOf(email) }, + searchHistory = searchHistory, ) } } @@ -241,8 +271,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 +301,5 @@ class MainActivity : ComponentActivity() { } } } - return true } } diff --git a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt index 184870e..c37f3bc 100644 --- a/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/TryFoxViewModel.kt @@ -25,6 +25,8 @@ 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 @@ -35,6 +37,8 @@ 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.isPerfAgainRetry +import org.mozilla.tryfox.ui.screens.selectPreferredPushComment import org.mozilla.tryfox.util.TREEHERDER import java.io.File @@ -122,6 +126,9 @@ class TryFoxViewModel( var selectedJobs by mutableStateOf>(emptyList()) private set + var successfulSearch by mutableStateOf(null) + private set + val isLoadingJobArtifacts = mutableStateMapOf() var onInstallApk: ((File) -> Unit)? = null @@ -129,7 +136,7 @@ class TryFoxViewModel( private val deviceSupportedAbis: List by lazy { supportedAbis } init { - if (revision != null) { + if (!revision.isNullOrBlank() && '@' !in revision) { searchJobsAndArtifacts() } cacheManager.cacheState.onEach { state -> @@ -191,6 +198,7 @@ class TryFoxViewModel( } fun searchJobsAndArtifacts() { + successfulSearch = null if (revision.isBlank()) { errorMessage = "Please enter a revision to search." return @@ -211,27 +219,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 (isPerfAgainRetry(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 +403,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", 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/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/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/DefaultTreeherderRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultTreeherderRepository.kt index 7477cb0..ea1a188 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 @@ -30,7 +30,21 @@ 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): NetworkResult { + return safeApiCall { treeherderApiService.getPushByAuthor(project = project, author = author) } + } + + 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..0907398 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,6 +8,12 @@ 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 /** @@ -19,15 +25,24 @@ 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") } - 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 -> @@ -46,8 +61,24 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos } 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 +89,11 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos preferences[PreferenceKeys.LAN_SHARED_SECRET] = identity.sharedSecret } } + + private fun decodeSearchHistory(serializedHistory: String): List = + runCatching { json.decodeFromString>(serializedHistory) }.getOrDefault(emptyList()) + + private companion object { + val json = Json { ignoreUnknownKeys = true } + } } 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/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/TreeherderRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/TreeherderRepository.kt index a6c647a..71a6980 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,21 @@ 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): 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..cb32aae 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,6 +1,7 @@ package org.mozilla.tryfox.data.repositories import kotlinx.coroutines.flow.Flow +import org.mozilla.tryfox.data.SearchHistoryEntry import org.mozilla.tryfox.lan.LanReceiveIdentity /** @@ -12,6 +13,7 @@ interface UserDataRepository { * A flow that emits the last searched email. */ val lastSearchedEmailFlow: Flow + val searchHistoryFlow: Flow> val lanReceiveIdentityFlow: Flow /** @@ -19,5 +21,6 @@ interface UserDataRepository { * @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) } 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..6caa9f3 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,7 @@ 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.DefaultMozillaArchiveRepository import org.mozilla.tryfox.data.repositories.DefaultTreeherderRepository import org.mozilla.tryfox.data.repositories.DefaultUserDataRepository @@ -31,12 +33,19 @@ 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.HistoryRepository +import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository 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,9 +56,10 @@ 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.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_RELEASE @@ -146,6 +156,7 @@ 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 { DefaultLanMessageHistoryRepository( @@ -164,6 +175,11 @@ val repositoryModule = module { ) } single { DefaultIntentManager(androidContext()) } + single { ApkInstallCoordinator(androidContext()) } + 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()) } @@ -186,9 +202,10 @@ val viewModelModule = module { params.getOrNull(), ) } - viewModel { HistoryViewModel(get(), get(), get(), get(), get(named("IODispatcher"))) } + viewModel { HistoryViewModel(get(), get(), get(), get(), get(), get(named("IODispatcher"))) } viewModel { ReceiveFromDesktopViewModel(get()) } viewModel { ReceiveMessageHistoryViewModel(get(), get(named("IODispatcher"))) } + viewModel { SearchHistoryViewModel(get()) } viewModel { val releaseRepositories = listOf( get(named(FENIX)), @@ -205,10 +222,14 @@ val viewModelModule = module { get(), get(), get(), + get(), get(named("IODispatcher")), + 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..87933c5 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/download/worker/ApkDownloadWorker.kt @@ -0,0 +1,326 @@ +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.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() + + 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..64f6fd2 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/ApkInstallCoordinator.kt @@ -0,0 +1,238 @@ +package org.mozilla.tryfox.install + +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +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 java.io.File +import java.util.concurrent.atomic.AtomicInteger + +/** Owns PackageInstaller sessions started from unified search. */ +@Suppress("NestedBlockDepth", "TooManyFunctions") +class ApkInstallCoordinator(private val context: Context) { + private data class Operation(val artifactKey: String, val file: File, val packageName: String) + + private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) + private val packageInstaller = 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) { + if (activeOperationId != null) return + activeOperationId = artifactKey + _states.value = _states.value + (artifactKey to InstallState.Installing) + scope.launch { prepareAndInstall(artifactKey, file) } + } + + 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) + } + + fun onInstallResult(intent: Intent) { + val artifactKey = intent.getStringExtra(EXTRA_ARTIFACT_KEY) ?: return + val operation = operations[artifactKey] ?: return + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, PackageInstaller.STATUS_FAILURE) + val statusMessage = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE).orEmpty() + 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_SUCCESS -> succeed(artifactKey) + 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) { + 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 operation = Operation(artifactKey, file, packageName) + operations[artifactKey] = operation + val incomingVersion = archive.let(PackageInfoCompat::getLongVersionCode) + val installedVersion = installedVersion(packageName) + if (installedVersion != null && installedVersion > incomingVersion) { + conflict(artifactKey, packageName) + return + } + commit(operation) + } + + private fun commit(operation: Operation) { + var sessionId: Int? = null + try { + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL).apply { + setAppPackageName(operation.packageName) + setSize(operation.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 -> + operation.file.inputStream().use { input -> + session.openWrite("base.apk", 0, operation.file.length()).use { output -> + input.copyTo(output) + session.fsync(output) + } + } + session.commit(statusReceiver(operation.artifactKey)) + } + } catch (e: Exception) { + sessionId?.let(packageInstaller::abandonSession) + logcat(LogPriority.ERROR, TAG) { "Could not create install session: ${e.message}" } + fail(operation.artifactKey, "Could not start installation.") + } + } + + private fun statusReceiver(artifactKey: String) = PendingIntent.getBroadcast( + context, + requestCodes.incrementAndGet(), + Intent(context, InstallResultReceiver::class.java).putExtra(EXTRA_ARTIFACT_KEY, artifactKey), + 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 fun succeed(artifactKey: String) { + val packageName = operations[artifactKey]?.packageName ?: return + activeOperationId = null + operations.remove(artifactKey) + _states.value = _states.value + (artifactKey to InstallState.Installed(packageName)) + _successfulInstalls.tryEmit(artifactKey) + } + + 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 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." + } +} 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..34b02ee --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/install/InstallResultReceiver.kt @@ -0,0 +1,12 @@ +package org.mozilla.tryfox.install + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import org.koin.core.context.GlobalContext + +class InstallResultReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + GlobalContext.get().get().onInstallResult(intent) + } +} 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/network/TreeherderApiService.kt b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt index d4b79ce..d8df458 100644 --- a/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt +++ b/app/src/main/java/org/mozilla/tryfox/network/TreeherderApiService.kt @@ -16,13 +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("author") author: String, ): 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/") suspend fun getJobsForPush( @Query("push_id") pushId: Int, 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..2340740 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,29 +1,63 @@ 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_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.REFERENCE_BROWSER +private const val PADDED_FOREGROUND_ICON_SCALE = 1.8f + @Composable -fun AppIcon(appName: String, modifier: Modifier = Modifier) { +fun AppIcon( + appName: String, + modifier: Modifier = Modifier, + useSearchResultVariant: Boolean = false, +) { 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 -> { + (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_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 -> { + (if (useSearchResultVariant) R.drawable.ic_focus_debug_foreground_v2 else R.drawable.ic_focus) to + R.string.app_icon_focus_description + } + appName == FOCUS_NIGHTLY -> { + (if (useSearchResultVariant) R.drawable.ic_focus_nightly_foreground else R.drawable.ic_focus) to + R.string.app_icon_focus_description + } + appName == FOCUS_BETA -> { + (if (useSearchResultVariant) R.drawable.ic_focus_beta_foreground else 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") @@ -32,11 +66,22 @@ fun AppIcon(appName: String, modifier: Modifier = Modifier) { } if (iconResId != null && contentDescResId != null) { - Image( - painter = painterResource(id = iconResId), - contentDescription = stringResource(id = contentDescResId), - modifier = modifier, - ) + val isPaddedSearchResultForeground = useSearchResultVariant && appName in setOf(FENIX, FENIX_NIGHTLY, FENIX_BETA, FOCUS_BETA) + if (isPaddedSearchResultForeground) { + Box(modifier = modifier.clipToBounds()) { + Image( + painter = painterResource(id = iconResId), + contentDescription = stringResource(id = contentDescResId), + modifier = Modifier.fillMaxSize().scale(PADDED_FOREGROUND_ICON_SCALE), + ) + } + } else { + Image( + painter = painterResource(id = iconResId), + contentDescription = stringResource(id = contentDescResId), + modifier = modifier, + ) + } Spacer(modifier = Modifier.width(8.dp)) } } 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..1686e90 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 @@ -76,7 +77,6 @@ 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 +93,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 +110,8 @@ fun ArchiveGroupCard( pendingBuildOptions: List = emptyList(), onBuildSelected: (String) -> Unit = {}, onDismissBuildPicker: () -> Unit = {}, + installStates: Map = emptyMap(), + onOpenInstalledApp: (String) -> Unit = {}, ) { if (pendingBuildOptions.isNotEmpty()) { NightlyBuildPickerDialog( @@ -184,6 +186,8 @@ fun ArchiveGroupCard( onInstallClick, onUninstallClick, appState, + installStates, + onOpenInstalledApp, ) } @@ -433,9 +437,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 +492,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 +507,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), ) } } 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..ee77960 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 @@ -50,87 +51,67 @@ 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 annotatedString = rememberLinkedPushComment(comment) 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/PushUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/PushUiModel.kt index a2ec752..58ed410 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 @@ -6,4 +6,5 @@ data class PushUiModel( 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..8b4bc58 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,26 @@ 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.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? = null, private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO, private val currentTimeMillisProvider: () -> Long = System::currentTimeMillis, ) : ViewModel() { @@ -41,13 +44,12 @@ 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 ?: MutableStateFlow(emptyMap()) init { logcat(LogPriority.DEBUG, TAG) { "init" } @@ -56,6 +58,26 @@ class HistoryViewModel( cacheManager.checkCacheStatus() } + downloadCoordinator.downloads + .onEach { persistedDownloads -> + downloadStates.value = persistedDownloads.toDownloadStates() + } + .launchIn(viewModelScope) + + installCoordinator?.successfulInstalls + ?.onEach { artifactKey -> + _historyItems.value.firstOrNull { it.entry.uniqueKey == artifactKey }?.let { historyItem -> + try { + historyRepository.upsertHistoryEntry( + historyItem.entry.copy(lastInstallerLaunchTimestamp = currentTimeMillisProvider()), + ) + } catch (_: Exception) { + // History is best-effort; the install has already succeeded. + } + } + } + ?.launchIn(viewModelScope) + historyRepository.historyEntries .combine(cacheManager.cacheState) { entries, _ -> entries } .combine(cacheRefreshEvents) { entries, _ -> entries } @@ -77,12 +99,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,139 +123,50 @@ 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) { - viewModelScope.launch { - try { - historyRepository.upsertHistoryEntry( - historyItem.entry.copy(lastInstallerLaunchTimestamp = currentTimeMillisProvider()), - ) - } catch (_: Exception) { - // History is best-effort; never block installation. + installCoordinator?.install(historyItem.entry.uniqueKey, file) ?: run { + viewModelScope.launch { + try { + historyRepository.upsertHistoryEntry( + historyItem.entry.copy(lastInstallerLaunchTimestamp = currentTimeMillisProvider()), + ) + } catch (_: Exception) { + // History is best-effort; never block installation. + } + intentManager.installApk(file) } - intentManager.installApk(file) } } + 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 +183,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 +212,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 +261,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/HomeScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt index 17779ab..77da96f 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 @@ -12,7 +12,6 @@ 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 @@ -49,6 +48,7 @@ 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.install.InstallState import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.ui.composables.ArchiveGroupCard @@ -57,14 +57,12 @@ 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 homeViewModel The ViewModel for the Home screen. */ @@ -72,8 +70,7 @@ import java.io.File @Composable fun HomeScreen( modifier: Modifier = Modifier, - onNavigateToTreeherder: () -> Unit, - onNavigateToProfile: () -> Unit, + onNavigateToSearch: () -> Unit, onNavigateToQrScanner: () -> Unit, onNavigateToReceiveFromDesktop: () -> Unit, onNavigateToHistory: () -> Unit, @@ -81,6 +78,7 @@ fun HomeScreen( ) { 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) { @@ -123,13 +121,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( @@ -202,7 +194,9 @@ fun HomeScreen( AppComponent( app = app, onDownloadClick = { homeViewModel.downloadNightlyApk(it) }, - onInstallClick = { homeViewModel.installApk(it) }, + onInstallClick = homeViewModel::installHomeApk, + installStates = installStates, + onOpenInstalledApp = homeViewModel::openInstalledApp, onOpenAppClick = { homeViewModel.openApp(it) }, onUninstallClick = { homeViewModel.uninstallApp(it) }, onDateSelected = { appName, date -> @@ -231,7 +225,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 }, ) @@ -288,7 +284,9 @@ private fun TopBarActionIcon( fun AppComponent( app: AppUiModel, onDownloadClick: (ApkUiModel) -> Unit, - onInstallClick: (File) -> Unit, + onInstallClick: (ApkUiModel) -> Unit, + installStates: Map, + onOpenInstalledApp: (String) -> Unit, onOpenAppClick: (String) -> Unit, onUninstallClick: (String) -> Unit, onDateSelected: (String, LocalDate) -> Unit, @@ -320,6 +318,8 @@ fun AppComponent( appState = appState, onDownloadClick = onDownloadClick, onInstallClick = onInstallClick, + installStates = installStates, + onOpenInstalledApp = onOpenInstalledApp, onOpenAppClick = { appState?.packageName?.let { onOpenAppClick(it) 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..a6d8350 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,6 +11,8 @@ 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 @@ -20,12 +22,21 @@ 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.HomeDataCacheRepository +import org.mozilla.tryfox.data.repositories.HomeDataSnapshot import org.mozilla.tryfox.data.repositories.ReleaseRepository 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.MozillaArchiveApk import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel @@ -46,7 +57,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 +65,13 @@ 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? = null, private val ioDispatcher: CoroutineDispatcher, + private val homeDataCacheRepository: HomeDataCacheRepository = EmptyHomeDataCacheRepository, private val supportedAbis: List = Build.SUPPORTED_ABIS.toList(), ) : ViewModel() { @@ -67,48 +80,34 @@ class HomeViewModel( private val _isRefreshing = MutableStateFlow(false) val isRefreshing: StateFlow = _isRefreshing.asStateFlow() + val installStates: StateFlow> = + installCoordinator?.states ?: MutableStateFlow(emptyMap()) + private val downloadStates = MutableStateFlow>(emptyMap()) + private var currentAppsByName: Map = emptyMap() + private var cachedAppsByName: Map = emptyMap() + private var initialLoadStarted = false + private var tryFoxCardDismissed = false + 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 - - 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)) - } - } else { - currentState.tryfoxApp - } - - currentState.copy( - apps = updatedApps, - tryfoxApp = updatedTryFoxApp, - cacheManagementState = newCacheState, - isDownloadingAnyFile = if (newCacheState is CacheManagementState.IdleEmpty) { - false - } else { - currentState.isDownloadingAnyFile - }, - ) + currentState.copy(cacheManagementState = newCacheState) } + syncLoadedStateDownloadStates() } .launchIn(viewModelScope) @@ -139,25 +138,77 @@ class HomeViewModel( } 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) + } + + 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, @@ -166,52 +217,110 @@ class HomeViewModel( 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, + 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, + ).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(), + 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() @@ -329,105 +438,32 @@ class HomeViewModel( } } - 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) ?: intentManager.installApk(file) + } + + fun installHomeApk(apkInfo: ApkUiModel) { + val file = File(apkInfo.apkDir, apkInfo.fileName) + installCoordinator?.install(apkInfo.uniqueKey, file) ?: intentManager.installApk(file) } fun uninstallApp(packageName: String) { @@ -467,6 +503,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 +515,7 @@ class HomeViewModel( state.copy( apps = state.apps + ( appName to app.copy( - apks = ApksResult.Success(convertParsedApksToUiModels(chosen)), + apks = chosenApks, pendingBuildOptions = emptyList(), ) ), @@ -485,6 +526,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 @@ -517,6 +559,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 +573,12 @@ class HomeViewModel( apks = newApksResult, selectedReleaseVersion = version, ) + updateCurrentApp(appName) { + it.copy(apks = newApksResult, selectedReleaseVersion = version) + } _homeScreenState.value = latestState.copy(apps = finalUpdatedApps) + syncLoadedStateDownloadStates() } } @@ -550,6 +599,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 +637,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 +671,12 @@ class HomeViewModel( mozillaPackageManager.launchApp(app) } + fun openInstalledApp(packageName: String) { + installCoordinator?.openInstalledApp(packageName) ?: mozillaPackageManager.launchApp(packageName) + } + fun dismissTryFoxCard() { + tryFoxCardDismissed = true _homeScreenState.update { currentState -> if (currentState !is HomeScreenState.Loaded) return@update currentState currentState.copy(tryfoxApp = null) @@ -627,6 +696,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/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..499e8ac 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 @@ -8,21 +8,27 @@ import kotlinx.coroutines.awaitAll 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.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.model.CacheManagementState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ArtifactUiModel @@ -30,6 +36,72 @@ 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 bugComment(revisions: List): String? { + return revisions.firstOrNull { revision -> + revision.comments.trimStart().startsWith("Bug ", ignoreCase = true) || + revision.comments.trimStart().startsWith("[Bug ", ignoreCase = true) + }?.comments +} + +internal fun selectPreferredPushComment( + revisions: List, + precedingPushRevisions: List> = emptyList(), +): String { + val ownComment = bugComment(revisions) ?: revisions.firstOrNull()?.comments.orEmpty().ifBlank { "No comment" } + val isPerfAgainRetry = ownComment.contains("Pushed via `mach try perf-again`", ignoreCase = true) + return if (isPerfAgainRetry) { + precedingPushRevisions.firstNotNullOfOrNull(::bugComment) ?: ownComment + } else { + ownComment + } +} + +internal fun isPerfAgainRetry(revisions: List): Boolean { + val firstComment = revisions.firstOrNull()?.comments.orEmpty() + return firstComment.contains("Pushed via `mach try perf-again`", ignoreCase = true) +} + +private val unsignedApkJobNamePattern = Regex("^build-apk-(.+)$", RegexOption.IGNORE_CASE) + +/** 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 +112,87 @@ 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, + ) + companion object { - private const val TAG = "ProfileViewModel" + private const val TAG = "SearchViewModel" + private const val MAX_PARALLEL_ARTIFACT_REQUESTS = 6 + 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", "android") } 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 _errorMessage = MutableStateFlow(null) val errorMessage: StateFlow = _errorMessage.asStateFlow() + private val _warningMessage = MutableStateFlow(null) + val warningMessage: StateFlow = _warningMessage.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 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,100 +204,222 @@ 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() + // 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 + } + + fun updateQuery(query: String) = updateAuthorEmail(query) + + fun updateSelectedProject(project: String) { + _selectedProject.value = project + _warningMessage.value = null + } + + 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 } } - private fun loadLastSearchedEmail() { + private fun searchByRevision(revision: String) { + if (revision.isBlank()) { + _errorMessage.value = "Please enter a revision to search." + return + } 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}" } + _isLoading.value = true + _errorMessage.value = null + _warningMessage.value = null + _pushes.value = emptyList() + when (val pushResult = fenixRepository.getPushByRevision(_selectedProject.value, revision)) { + is NetworkResult.Success -> { + val push = pushResult.data.results.firstOrNull() + if (push == null) { + _errorMessage.value = "No push found for project: ${_selectedProject.value}, 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 (isPerfAgainRetry(push.revisions)) { + when ( + val authorPushes = fenixRepository.getPushesByAuthor( + _selectedProject.value, + push.author, + ) + ) { + is NetworkResult.Success -> { + val pushIndex = authorPushes.data.results.indexOfFirst { it.id == push.id } + authorPushes.data.results + .take(pushIndex.coerceAtLeast(0)) + .asReversed() + .map { it.revisions } + } + is NetworkResult.Error -> emptyList() + } + } else { + emptyList() + } + _pushes.value = listOf( + PushUiModel( + pushComment = selectPreferredPushComment(push.revisions, precedingRevisions), + author = push.author, + jobs = signedJobs, + unsignedJobs = unsignedJobs, + revision = push.revision, + pushTimestamp = push.pushTimestamp, + ), + ) + syncLoadedStateDownloadStates() + userDataRepository.recordSearch(_selectedProject.value, revision) + } + } else { + _errorMessage.value = "Error fetching jobs: ${(jobsResult as NetworkResult.Error).message}" + } + } + } + is NetworkResult.Error -> { + _errorMessage.value = "Error fetching revision details for ${_selectedProject.value}: ${pushResult.message}" + } } + _isLoading.value = false } } - fun updateAuthorEmail(email: String) { - logcat(LogPriority.DEBUG, TAG) { "Updating author email to: $email" } - _authorEmail.value = email - } - 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 + } + searchPushes( + queryToRecord = emailToSearch, + project = projectToSearch, + ) { fenixRepository.getPushesByAuthor(projectToSearch, emailToSearch) } + } + + private fun searchRecentPushes() { + val projectToSearch = _selectedProject.value + searchPushes( + queryToRecord = null, + project = projectToSearch, + ) { fenixRepository.getRecentPushes(projectToSearch) } + } + + private fun searchPushes( + queryToRecord: String?, + project: String, + request: suspend () -> NetworkResult, + ) { 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)) { - is NetworkResult.Success -> { + val artifactSemaphore = Semaphore(MAX_PARALLEL_ARTIFACT_REQUESTS) + var requestToRun = request + var retriedRecentPushes = false + var fetchedPushCount = 0 + + searchLoop@ while (true) { + when (val result = requestToRun()) { + is NetworkResult.Success -> { + fetchedPushCount += result.data.results.size logcat( LogPriority.DEBUG, TAG, - ) { "getPushesByAuthor success, processing ${result.data.results.size} pushes" } - val pushesWithJobsAndArtifacts = result.data.results.map { pushResult -> + ) { "Push lookup succeeded; processing ${result.data.results.size} pushes" } + val pushesWithJobsAndArtifacts = result.data.results.mapIndexed { pushIndex, 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 -> + val (signedCandidates, unsignedCandidates) = selectJobsBySigning( + jobsResult.data.results.filter(::isAndroidApkCandidate), + ) + val selectedJobs = signedCandidates + unsignedCandidates + if (selectedJobs.isNotEmpty()) { + val jobsWithArtifacts = selectedJobs.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 + val artifactResult = artifactSemaphore.withPermit { + fetchArtifacts(jobDetails.taskId) + } + artifactResult.artifacts.takeIf { it.isNotEmpty() }?.let { artifacts -> + jobWithArtifacts(jobDetails, artifacts) } } }.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" - } + val visibleJobs = filterRedundantUnsignedApkJobs(jobsWithArtifacts) + if (visibleJobs.isNotEmpty()) { PushUiModel( - pushComment = determinedPushComment, + pushComment = selectPreferredPushComment( + revisions = pushResult.revisions, + precedingPushRevisions = result.data.results + .take(pushIndex) + .asReversed() + .map { it.revisions }, + ), author = pushResult.author, - jobs = jobsWithArtifacts, + jobs = orderApkJobs(visibleJobs.filter(JobDetailsUiModel::isSignedBuild)), + unsignedJobs = orderApkJobs(visibleJobs.filterNot(JobDetailsUiModel::isSignedBuild)), revision = pushResult.revision, pushTimestamp = pushResult.pushTimestamp, ) @@ -189,7 +431,7 @@ class ProfileViewModel( } } else { logcat(LogPriority.VERBOSE, TAG) { - "No signed, non-test jobs for push ID: ${pushResult.id}" + "No eligible, non-test APK jobs for push ID: ${pushResult.id}" } null } @@ -204,23 +446,45 @@ class ProfileViewModel( }.awaitAll().filterNotNull() _pushes.value = pushesWithJobsAndArtifacts + syncLoadedStateDownloadStates() + if (pushesWithJobsAndArtifacts.isNotEmpty()) { + queryToRecord?.let { userDataRepository.recordSearch(project, it) } + } logcat(TAG) { "Search finished, ${_pushes.value.size} pushes with artifacts found." } + if (queryToRecord == null && !retriedRecentPushes && pushesWithJobsAndArtifacts.isEmpty()) { + retriedRecentPushes = true + requestToRun = { + fenixRepository.getRecentPushes( + project = project, + count = 50, + offset = 10, + ) + } + logcat(TAG) { "No usable APKs in the newest 10 pushes; searching the next 50." } + continue@searchLoop + } if (pushesWithJobsAndArtifacts.isEmpty()) { - _errorMessage.value = "No signed builds found for this author." - logcat(TAG) { "No signed builds found for author." } + _errorMessage.value = "No push was found with a job that produced an APK." + logcat(TAG) { _errorMessage.value.orEmpty() } + } else if (pushesWithJobsAndArtifacts.size < fetchedPushCount) { + _warningMessage.value = + "Showing ${pushesWithJobsAndArtifacts.size} of $fetchedPushCount fetched pushes. " + + "The remaining pushes did not contain a job that produced an APK." } } - is NetworkResult.Error -> { - logcat(LogPriority.ERROR, TAG) { "Error fetching pushes: ${result.message}" } - _errorMessage.value = "Error fetching pushes: ${result.message}" + is NetworkResult.Error -> { + logcat(LogPriority.ERROR, TAG) { "Error fetching pushes: ${result.message}" } + _errorMessage.value = "Error fetching pushes: ${result.message}" + } } + break@searchLoop } _isLoading.value = false } } - private suspend fun fetchArtifacts(taskId: String): List { + 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 +495,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 +525,64 @@ 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) } } } + private 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") + } + + /** 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 +629,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 +640,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 +651,48 @@ 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 + installCoordinator.install(artifactUiModel.uniqueKey, downloadState.file) } + 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 +717,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 +741,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) } 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..cde37cd --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchResultCard.kt @@ -0,0 +1,222 @@ +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 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.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..2ca94bf --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SearchScreen.kt @@ -0,0 +1,490 @@ +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.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.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_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 errorMessage by searchViewModel.errorMessage.collectAsState() + val warningMessage by searchViewModel.warningMessage.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?.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()) { + items(pushes.size) { index -> + AnimatedVisibility( + visible = showCurrentSearch, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically(), + ) { + PushResultCard( + push = pushes[index], + onDownloadClick = searchViewModel::downloadArtifact, + onInstallClick = searchViewModel::installArtifact, + onOpenClick = searchViewModel::openInstalledApp, + installStates = installStates, + activeInstallKey = activeInstallKey, + testTag = "search_push_${pushes[index].revision}", + ) + } + } + } + } + } + } +} + +@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), + ) { + CircularProgressIndicator( + modifier = Modifier.size(36.dp), + strokeWidth = 3.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, + ) + } +} 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/Consts.kt b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt index 736e68a..7a3e2f2 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Consts.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Consts.kt @@ -6,6 +6,8 @@ const val FENIX_BETA = "fenix-beta" 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 REFERENCE_BROWSER = "reference-browser" const val TREEHERDER = "treeherder" const val TRYFOX = "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/values/strings.xml b/app/src/main/res/values/strings.xml index f4e3b3a..46bab63 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,31 +1,23 @@ TryFox - Profile - Search Treeherder + Search builds History + Recent searches Scan QR code Receive from desktop Fetching latest nightly builds... Loading initial app data... An unknown error occurred. Back - Search Fenix Artifacts + 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 @@ -37,6 +29,12 @@ 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 +42,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 @@ -140,4 +149,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/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/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/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/FakeCacheManager.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeCacheManager.kt index adfcc0f..7bfa58c 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 @@ -22,6 +22,11 @@ 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 _cacheState.value = CacheManagementState.IdleEmpty 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..f8826cc 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,6 +2,9 @@ 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 @@ -12,11 +15,22 @@ 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 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) { @@ -26,5 +40,6 @@ class FakeUserDataRepository : UserDataRepository { // 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/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/HistoryViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HistoryViewModelTest.kt index 96ab561..cce9688 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,27 @@ package org.mozilla.tryfox.ui.screens 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.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.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 java.io.File @OptIn(ExperimentalCoroutinesApi::class) @@ -27,6 +34,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 +139,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 +184,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 +223,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 +251,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) @@ -208,28 +382,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 +414,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 +439,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,14 +522,13 @@ class HistoryViewModelTest { private fun createViewModel( cacheManager: FakeCacheManager, historyRepository: FakeHistoryRepository, - downloadFileRepository: org.mozilla.tryfox.data.repositories.DownloadFileRepository = - FakeDownloadFileRepository(downloadProgressDelayMillis = 0), + downloadCoordinator: ApkDownloadCoordinator = FakeApkDownloadCoordinator(), intentManager: FakeIntentManager = FakeIntentManager(), currentTimeMillisProvider: () -> Long = { 0L }, ): HistoryViewModel = HistoryViewModel( historyRepository = historyRepository, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = downloadCoordinator, cacheManager = cacheManager, intentManager = intentManager, ioDispatcher = mainCoroutineRule.testDispatcher, @@ -336,100 +560,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/HomeViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeViewModelTest.kt index fc80873..5e1488d 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,7 +1,12 @@ 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 @@ -19,12 +24,8 @@ 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.whenever import org.mockito.quality.Strictness import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.FakeMozillaArchiveRepository @@ -34,12 +35,20 @@ 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.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.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.model.AppState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.MozillaArchiveApk @@ -66,9 +75,7 @@ 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() @TempDir @@ -174,22 +181,127 @@ class HomeViewModelTest { @BeforeEach fun setUp() { fakeCacheManager = FakeCacheManager(tempCacheDir) + fakeDownloadCoordinator = FakeApkDownloadCoordinator() viewModel = createViewModel() } private fun createViewModel( releaseRepositories: List = emptyList(), mozillaPackageManager: MozillaPackageManager = FakeMozillaPackageManager(), + homeDataCacheRepository: HomeDataCacheRepository = FakeHomeDataCacheRepository(), ) = HomeViewModel( releaseRepositories = releaseRepositories, - downloadFileRepository = downloadFileRepository, + downloadCoordinator = fakeDownloadCoordinator, mozillaPackageManager = mozillaPackageManager, cacheManager = fakeCacheManager, intentManager = intentManager, ioDispatcher = mainCoroutineRule.testDispatcher, + homeDataCacheRepository = homeDataCacheRepository, supportedAbis = listOf("arm64-v8a", "x86_64", "armeabi-v7a"), ) + 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 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 { return try { val inputFormat = LocalDateTime.Format { byUnicodePattern("yyyy-MM-dd-HH-mm-ss") } @@ -214,6 +326,141 @@ 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 `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 +808,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 +859,7 @@ class HomeViewModelTest { (downloadedApkInfo.downloadState as DownloadState.Downloaded).file.path, ) assertTrue(fakeCacheManager.checkCacheStatusCalled) - assertTrue(intentManager.wasInstallApkCalled) + assertFalse(intentManager.wasInstallApkCalled) assertFalse( loadedState.isDownloadingAnyFile, "isDownloadingAnyFile should be false after success", @@ -618,13 +885,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 } 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/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/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/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/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/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/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.