diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/VersionSelectorTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/VersionSelectorTest.kt index a443dd6..aca8d14 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/VersionSelectorTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/VersionSelectorTest.kt @@ -1,6 +1,8 @@ package org.mozilla.tryfox.ui.composables import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsOff +import androidx.compose.ui.test.assertIsOn import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag @@ -117,6 +119,7 @@ class VersionSelectorTest { } composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-release", useUnmergedTree = true).performClick() composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).performClick() assertTrue(confirmedVersion == "153.0.4-RC2") @@ -136,6 +139,89 @@ class VersionSelectorTest { } composeTestRule.onNodeWithTag("release_version_chip_fenix-beta", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-beta", useUnmergedTree = true).performClick() composeTestRule.onNodeWithTag("release_version_variant_153_0b5-RC2", useUnmergedTree = true).assertIsDisplayed() } + + @Test + fun releaseCandidates_areHiddenUntilShown() { + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + VersionSelector( + appName = FENIX_RELEASE, + selectedReleaseVersion = "153.0.4", + availableReleaseVersions = listOf("153.0.4", "153.0.4-RC2", "153.0.4-RC1"), + onReleaseVersionSelected = {}, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-release", useUnmergedTree = true).assertIsOff() + composeTestRule.onNodeWithTag("release_version_variant_153_0_4", useUnmergedTree = true).assertIsDisplayed() + assertTrue( + composeTestRule + .onAllNodesWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty(), + ) + } + + @Test + fun releaseCandidates_areShownWhenAnRcIsSelected() { + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + VersionSelector( + appName = FENIX_RELEASE, + selectedReleaseVersion = "153.0.4-RC2", + availableReleaseVersions = listOf("153.0.4", "153.0.4-RC2", "153.0.4-RC1"), + onReleaseVersionSelected = {}, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-release", useUnmergedTree = true).assertIsOn() + composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).assertIsSelected() + } + + @Test + fun releaseCandidates_areShownWhenOnlyRcBuildsExist() { + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + VersionSelector( + appName = FENIX_RELEASE, + selectedReleaseVersion = null, + availableReleaseVersions = listOf("153.0.4-RC2", "153.0.4-RC1"), + onReleaseVersionSelected = {}, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-release", useUnmergedTree = true).assertIsOn() + composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun focusSelector_hasNoReleaseCandidateCheckbox() { + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + VersionSelector( + appName = FOCUS_RELEASE, + selectedReleaseVersion = "155.0.1", + availableReleaseVersions = listOf("155.0.1", "154.0.2"), + onReleaseVersionSelected = {}, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick() + assertTrue( + composeTestRule + .onAllNodesWithTag("release_version_show_candidates_focus-release", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty(), + ) + } } diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt index 1e29051..dac418f 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/screens/HomeAppCardTest.kt @@ -65,6 +65,7 @@ class HomeAppCardTest { onDateSelected = { _, _ -> }, dateValidator = { true }, onReleaseVersionSelected = { _, _ -> }, + onReleaseMajorBrowsed = { _, _ -> }, onBuildSelected = { _, _ -> }, onDismissBuildPicker = {}, modifier = Modifier.width(350.dp), @@ -139,6 +140,7 @@ class HomeAppCardTest { onDateSelected = { _, _ -> }, dateValidator = { true }, onReleaseVersionSelected = { _, _ -> }, + onReleaseMajorBrowsed = { _, _ -> }, onBuildSelected = { _, _ -> }, onDismissBuildPicker = {}, ) @@ -198,6 +200,7 @@ class HomeAppCardTest { onDateSelected = { _, date -> selectedDate = date }, dateValidator = { true }, onReleaseVersionSelected = { _, _ -> }, + onReleaseMajorBrowsed = { _, _ -> }, onBuildSelected = { _, _ -> }, onDismissBuildPicker = {}, ) @@ -255,6 +258,7 @@ class HomeAppCardTest { onDateSelected = { _, _ -> }, dateValidator = { true }, onReleaseVersionSelected = { _, _ -> }, + onReleaseMajorBrowsed = { _, _ -> }, onBuildSelected = { _, _ -> }, onDismissBuildPicker = {}, ) diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt index 1d72e3c..f7ef934 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultMozillaArchiveRepository.kt @@ -1,11 +1,11 @@ package org.mozilla.tryfox.data.repositories import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock import kotlinx.datetime.DateTimeUnit +import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import kotlinx.datetime.TimeZone import kotlinx.datetime.minus @@ -23,6 +23,8 @@ import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_RELEASE import retrofit2.HttpException import java.net.HttpURLConnection +import java.util.concurrent.ConcurrentHashMap +import kotlin.time.Duration.Companion.seconds class DefaultMozillaArchiveRepository( private val mozillaArchivesApiService: MozillaArchivesApiService, @@ -30,8 +32,11 @@ class DefaultMozillaArchiveRepository( private val mozillaArchiveHtmlParser: MozillaArchiveHtmlParser = MozillaArchiveHtmlParser(), ) : MozillaArchiveRepository { + private val listingLocks = ConcurrentHashMap() + private val listingCache = ConcurrentHashMap() + companion object { - private const val CANDIDATE_BUILD_INDEX_CONCURRENCY = 4 + private val LISTING_CACHE_TTL = 10.seconds const val ARCHIVE_MOZILLA_BASE_URL = "https://archive.mozilla.org/pub/" const val RELEASES_FENIX_BASE_URL = "${ARCHIVE_MOZILLA_BASE_URL}fenix/releases/" const val CANDIDATES_FENIX_BASE_URL = "${ARCHIVE_MOZILLA_BASE_URL}fenix/candidates/" @@ -67,8 +72,10 @@ class DefaultMozillaArchiveRepository( override suspend fun getFenixReleaseBuilds(releaseType: ReleaseType): NetworkResult> { return try { - val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FENIX_BASE_URL) - val latestReleaseVersion = mozillaArchiveHtmlParser.parseFenixReleasesFromHtml(releasesHtml, releaseType) + val latestReleaseVersion = mozillaArchiveHtmlParser + .releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FENIX_BASE_URL), releaseType) + .firstOrNull() + .orEmpty() if (latestReleaseVersion.isEmpty()) { return NetworkResult.Error("No releases found for type $releaseType", null) @@ -96,11 +103,10 @@ class DefaultMozillaArchiveRepository( private suspend fun getFocusReleaseBuilds(releaseType: ReleaseType): NetworkResult> { return try { - val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FOCUS_BASE_URL) - val latestReleaseVersion = mozillaArchiveHtmlParser.parseFenixReleasesFromHtml( - releasesHtml, - releaseType, - ) + val latestReleaseVersion = mozillaArchiveHtmlParser + .releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FOCUS_BASE_URL), releaseType) + .firstOrNull() + .orEmpty() if (latestReleaseVersion.isEmpty()) { return NetworkResult.Error("No releases found for Focus", null) @@ -120,13 +126,8 @@ class DefaultMozillaArchiveRepository( override suspend fun getFenixReleaseVersions(releaseType: ReleaseType): NetworkResult> { return try { - val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FENIX_BASE_URL) - val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, releaseType) - val candidateVersions = fetchFenixCandidateVersions(releaseType) - val versions = (releaseVersions + candidateVersions) - .distinct() - .sortedWith(mozillaArchiveHtmlParser::compareReleaseVersions) - .reversed() + val versions = mozillaArchiveHtmlParser + .releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FENIX_BASE_URL), releaseType) if (versions.isEmpty()) { return NetworkResult.Error("No release versions found for type $releaseType", null) @@ -140,6 +141,33 @@ class DefaultMozillaArchiveRepository( } } + override suspend fun getFenixCandidateBaseVersions(releaseType: ReleaseType): NetworkResult> { + return try { + NetworkResult.Success( + mozillaArchiveHtmlParser + .candidateBaseVersionsFromDirectories(getDirectoryListing(CANDIDATES_FENIX_BASE_URL), releaseType), + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + NetworkResult.Error("Failed to fetch Fenix candidates: ${e.message}", e) + } + } + + override suspend fun getFenixCandidateVersions(baseVersion: String): NetworkResult> { + return try { + val buildsHtml = mozillaArchivesApiService.getHtmlPage(archiveUrlForCandidateBuilds(baseVersion)) + NetworkResult.Success( + mozillaArchiveHtmlParser.parseCandidateBuildNumbersFromHtml(buildsHtml) + .map { buildNumber -> "$baseVersion-RC$buildNumber" }, + ) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + NetworkResult.Error("Failed to fetch Fenix $baseVersion candidates: ${e.message}", e) + } + } + override suspend fun getFocusReleaseVersions(): NetworkResult> { return getFocusReleaseVersions(ReleaseType.Release) } @@ -150,8 +178,8 @@ class DefaultMozillaArchiveRepository( private suspend fun getFocusReleaseVersions(releaseType: ReleaseType): NetworkResult> { return try { - val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FOCUS_BASE_URL) - val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, releaseType) + val releaseVersions = mozillaArchiveHtmlParser + .releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FOCUS_BASE_URL), releaseType) if (releaseVersions.isEmpty()) { return NetworkResult.Error("No Focus release versions found", null) @@ -261,29 +289,6 @@ class DefaultMozillaArchiveRepository( return NetworkResult.Success(apks) } - private suspend fun fetchFenixCandidateVersions( - releaseType: ReleaseType, - ): List { - val candidatesHtml = getHtmlPageOrNull(CANDIDATES_FENIX_BASE_URL) ?: return emptyList() - val candidateBases = mozillaArchiveHtmlParser - .parseFenixCandidateVersionsFromHtml(candidatesHtml, releaseType) - - return coroutineScope { - candidateBases - .chunked(CANDIDATE_BUILD_INDEX_CONCURRENCY) - .flatMap { candidates -> - candidates.map { baseVersion -> - async { - val buildsHtml = getHtmlPageOrNull(archiveUrlForCandidateBuilds(baseVersion)) - ?: return@async emptyList() - mozillaArchiveHtmlParser.parseCandidateBuildNumbersFromHtml(buildsHtml) - .map { buildNumber -> "$baseVersion-RC$buildNumber" } - } - }.awaitAll().flatten() - } - } - } - override suspend fun isPublished(url: String): Boolean = try { // Only archive URLs are built from directory listings; others come from real asset links. !url.startsWith(ARCHIVE_MOZILLA_BASE_URL) || @@ -296,12 +301,19 @@ class DefaultMozillaArchiveRepository( private data class CandidateVersion(val baseVersion: String, val buildNumber: Int) - private suspend fun getHtmlPageOrNull(url: String): String? = try { - mozillaArchivesApiService.getHtmlPage(url) - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - null + private class CachedListing(val fetchedAt: Instant, val directories: List) + + /** + * Fetches an index page such as `releases/` as its directory names. Release and beta cards load + * concurrently and read the same listings, so a per-URL lock makes the second caller wait for + * the first's request, and successful results are reused for [LISTING_CACHE_TTL]; the short TTL + * keeps a pull-to-refresh fetching fresh data. Different URLs are fetched in parallel. + */ + private suspend fun getDirectoryListing(url: String): List = listingLocks.getOrPut(url) { Mutex() }.withLock { + val now = clock.now() + listingCache[url]?.takeIf { now - it.fetchedAt < LISTING_CACHE_TTL }?.let { return@withLock it.directories } + mozillaArchiveHtmlParser.parseDirectoryNamesFromHtml(mozillaArchivesApiService.getHtmlPage(url)) + .also { listingCache[url] = CachedListing(now, it) } } private fun parseCandidateVersion(version: String): CandidateVersion? { diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/FenixBetaReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/FenixBetaReleaseRepository.kt index 3b2cb0a..f247fac 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/FenixBetaReleaseRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/FenixBetaReleaseRepository.kt @@ -21,6 +21,14 @@ class FenixBetaReleaseRepository( return mozillaArchiveRepository.getFenixReleaseVersions(ReleaseType.Beta) } + override suspend fun getCandidateBaseVersions(): NetworkResult> { + return mozillaArchiveRepository.getFenixCandidateBaseVersions(ReleaseType.Beta) + } + + override suspend fun getCandidateVersions(baseVersion: String): NetworkResult> { + return mozillaArchiveRepository.getFenixCandidateVersions(baseVersion) + } + override suspend fun getReleasesForVersion(version: String): NetworkResult> { return mozillaArchiveRepository.getFenixReleaseBuildsForVersion(version, ReleaseType.Beta) } diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/FenixReleaseReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/FenixReleaseReleaseRepository.kt index 295ca19..a51ebbd 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/FenixReleaseReleaseRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/FenixReleaseReleaseRepository.kt @@ -21,6 +21,14 @@ class FenixReleaseReleaseRepository( return mozillaArchiveRepository.getFenixReleaseVersions(ReleaseType.Release) } + override suspend fun getCandidateBaseVersions(): NetworkResult> { + return mozillaArchiveRepository.getFenixCandidateBaseVersions(ReleaseType.Release) + } + + override suspend fun getCandidateVersions(baseVersion: String): NetworkResult> { + return mozillaArchiveRepository.getFenixCandidateVersions(baseVersion) + } + override suspend fun getReleasesForVersion(version: String): NetworkResult> { return mozillaArchiveRepository.getFenixReleaseBuildsForVersion(version, ReleaseType.Release) } diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt index ab26813..cf62bc5 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/MozillaArchiveRepository.kt @@ -25,6 +25,15 @@ interface MozillaArchiveRepository { suspend fun getFenixReleaseVersions(releaseType: ReleaseType = ReleaseType.Release): NetworkResult> + /** + * Lists base versions (e.g. `157.0`, `158.0b4`) that have a `-candidates` directory. This is a + * single request; build numbers are resolved separately via [getFenixCandidateVersions]. + */ + suspend fun getFenixCandidateBaseVersions(releaseType: ReleaseType): NetworkResult> + + /** Lists the RC versions (e.g. `157.0-RC2`, `157.0-RC1`) available for one candidate base version. */ + suspend fun getFenixCandidateVersions(baseVersion: String): NetworkResult> + suspend fun getFenixReleaseBuildsForVersion( version: String, releaseType: ReleaseType = ReleaseType.Release, diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/ReleaseRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/ReleaseRepository.kt index ef1d1e8..5c37cb8 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/ReleaseRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/ReleaseRepository.kt @@ -26,5 +26,11 @@ interface DateAwareReleaseRepository : ReleaseRepository { interface VersionAwareReleaseRepository : ReleaseRepository { suspend fun getAvailableReleaseVersions(): NetworkResult> + /** Base versions that have release candidates, from a single listing request. */ + suspend fun getCandidateBaseVersions(): NetworkResult> = NetworkResult.Success(emptyList()) + + /** RC versions for one candidate base version, selectable via [getReleasesForVersion]. */ + suspend fun getCandidateVersions(baseVersion: String): NetworkResult> = NetworkResult.Success(emptyList()) + suspend fun getReleasesForVersion(version: String): NetworkResult> } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/VersionSelector.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/VersionSelector.kt index 26368e6..25ae77d 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/VersionSelector.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/VersionSelector.kt @@ -17,12 +17,17 @@ import androidx.compose.foundation.layout.fillMaxHeight 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.wrapContentWidth import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.selection.toggleable import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material3.Checkbox +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,9 +37,12 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberModalBottomSheetState 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.rememberUpdatedState +import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -42,6 +50,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics @@ -49,8 +58,11 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import org.mozilla.tryfox.R +import org.mozilla.tryfox.ui.models.ReleaseCandidatesUiState import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FOCUS_BETA +import org.mozilla.tryfox.util.isReleaseCandidateVersion +import org.mozilla.tryfox.util.releaseVersionMajor @Composable internal fun VersionSelector( @@ -58,9 +70,15 @@ internal fun VersionSelector( selectedReleaseVersion: String?, availableReleaseVersions: List, onReleaseVersionSelected: (String) -> Unit, + releaseCandidates: ReleaseCandidatesUiState = ReleaseCandidatesUiState(), + onReleaseMajorBrowsed: (Int) -> Unit = {}, ) { var showSelector by remember { mutableStateOf(false) } val selectedVersion = selectedReleaseVersion ?: availableReleaseVersions.firstOrNull() + // Release candidates are hidden, and not fetched, unless asked for. They're shown by default + // when an RC is already selected; the picker also turns them on for majors that only have RCs. + var showReleaseCandidatesChoice by rememberSaveable { mutableStateOf(null) } + val showReleaseCandidates = showReleaseCandidatesChoice ?: (selectedVersion?.let(::isReleaseCandidateVersion) == true) Row( verticalAlignment = Alignment.CenterVertically, @@ -88,6 +106,10 @@ internal fun VersionSelector( appName = appName, selectedVersion = selectedVersion, availableVersions = availableReleaseVersions, + releaseCandidates = releaseCandidates, + onMajorBrowsed = onReleaseMajorBrowsed, + showReleaseCandidates = showReleaseCandidates, + onShowReleaseCandidatesChanged = { showReleaseCandidatesChoice = it }, onDismiss = { showSelector = false }, onConfirm = { version -> showSelector = false @@ -100,29 +122,59 @@ internal fun VersionSelector( private const val MINIMUM_SUPPORTED_MAJOR_VERSION = 117 private val VersionPickerWheelHeight = 156.dp -private fun versionMajor(version: String): Int? = - Regex("^(\\d+)(?:\\.|$)").find(version)?.groupValues?.getOrNull(1)?.toIntOrNull() - @OptIn(ExperimentalMaterial3Api::class) @Composable private fun VersionSelectorSheet( appName: String, selectedVersion: String?, availableVersions: List, + releaseCandidates: ReleaseCandidatesUiState, + onMajorBrowsed: (Int) -> Unit, + showReleaseCandidates: Boolean, + onShowReleaseCandidatesChanged: (Boolean) -> Unit, onDismiss: () -> Unit, onConfirm: (String) -> Unit, ) { - val versionsByMajor = remember(availableVersions) { - availableVersions.mapNotNull { version -> versionMajor(version)?.let { it to version } }.groupBy({ it.first }, { it.second }) + val hasReleaseCandidates = releaseCandidates.baseVersions.isNotEmpty() || availableVersions.any(::isReleaseCandidateVersion) + val shownVersions = if (showReleaseCandidates) availableVersions else availableVersions.filterNot(::isReleaseCandidateVersion) + val versionsByMajor = remember(shownVersions) { + shownVersions.mapNotNull { version -> releaseVersionMajor(version)?.let { it to version } }.groupBy({ it.first }, { it.second }) + } + val publishedMajors = remember(availableVersions) { + availableVersions.filterNot(::isReleaseCandidateVersion).mapNotNull(::releaseVersionMajor).toSet() + } + val candidateMajors = remember(availableVersions, releaseCandidates.baseVersions) { + (availableVersions.filter(::isReleaseCandidateVersion) + releaseCandidates.baseVersions).mapNotNull(::releaseVersionMajor).toSet() } - val initialMajor = versionMajor(selectedVersion.orEmpty()) - ?: versionsByMajor.keys.maxOrNull() + // Majors that only have candidate builds so far are always reachable on the wheel. + val knownMajors = publishedMajors + candidateMajors + val initialMajor = releaseVersionMajor(selectedVersion.orEmpty()) + ?: publishedMajors.maxOrNull() + ?: candidateMajors.maxOrNull() ?: MINIMUM_SUPPORTED_MAJOR_VERSION - val maximumSupportedMajorVersion = versionsByMajor.keys.maxOrNull() ?: MINIMUM_SUPPORTED_MAJOR_VERSION - var activeMajor by remember(selectedVersion, availableVersions) { mutableStateOf(initialMajor) } - var draftVersion by remember(selectedVersion, availableVersions) { mutableStateOf(selectedVersion) } + val maximumSupportedMajorVersion = knownMajors.maxOrNull() ?: MINIMUM_SUPPORTED_MAJOR_VERSION + var activeMajor by remember(selectedVersion) { mutableStateOf(initialMajor) } + var draftVersion by remember(selectedVersion) { mutableStateOf(selectedVersion) } val isActiveMajorSelectable = activeMajor in MINIMUM_SUPPORTED_MAJOR_VERSION..maximumSupportedMajorVersion - val variants = if (isActiveMajorSelectable) versionsByMajor[activeMajor].orEmpty() else emptyList() + // Versions arrive newest first; list them oldest first to match the wheel, which counts up downwards. + val variants = if (isActiveMajorSelectable) versionsByMajor[activeMajor].orEmpty().asReversed() else emptyList() + // Builds that load later (e.g. RCs of the oldest beta) sort above the visible ones, and the list + // would otherwise keep its current first row in view, so start from the top whenever it changes. + val buildsListState = rememberLazyListState() + LaunchedEffect(variants) { buildsListState.scrollToItem(0) } + val isLoadingCandidates = showReleaseCandidates && activeMajor in releaseCandidates.loadingMajors + // The NumberPicker's text listener outlives recompositions, so read the latest bound through state. + val currentMaximumMajor by rememberUpdatedState(maximumSupportedMajorVersion) + + // A major with only candidate builds has nothing to show otherwise, so turn candidates on for it. + LaunchedEffect(activeMajor) { + if (!showReleaseCandidates && activeMajor in candidateMajors && activeMajor !in publishedMajors) { + onShowReleaseCandidatesChanged(true) + } + } + LaunchedEffect(activeMajor, showReleaseCandidates) { + if (showReleaseCandidates && isActiveMajorSelectable) onMajorBrowsed(activeMajor) + } val title = if (appName == FENIX_BETA || appName == FOCUS_BETA) { stringResource(R.string.version_selector_beta_title) } else { @@ -132,7 +184,7 @@ private fun VersionSelectorSheet( fun selectMajor(major: Int) { activeMajor = major - draftVersion = draftVersion?.takeIf { versionMajor(it) == major } + draftVersion = draftVersion?.takeIf { releaseVersionMajor(it) == major } } ModalBottomSheet( @@ -152,6 +204,26 @@ private fun VersionSelectorSheet( style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), ) + if (hasReleaseCandidates) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .toggleable( + value = showReleaseCandidates, + role = Role.Checkbox, + onValueChange = onShowReleaseCandidatesChanged, + ) + .testTag("release_version_show_candidates_${appName.lowercase()}") + .padding(bottom = 8.dp), + ) { + Checkbox(checked = showReleaseCandidates, onCheckedChange = null) + Text( + text = stringResource(R.string.version_selector_show_release_candidates), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(start = 8.dp), + ) + } + } Row( modifier = Modifier .fillMaxWidth() @@ -188,7 +260,7 @@ private fun VersionSelectorSheet( override fun afterTextChanged(text: Editable?) { text?.toString()?.toIntOrNull()?.takeIf { - it in MINIMUM_SUPPORTED_MAJOR_VERSION..maximumSupportedMajorVersion + it in MINIMUM_SUPPORTED_MAJOR_VERSION..currentMaximumMajor }?.let(::selectMajor) } }) @@ -197,6 +269,7 @@ private fun VersionSelectorSheet( } }, update = { picker -> + if (picker.maxValue != maximumSupportedMajorVersion) picker.maxValue = maximumSupportedMajorVersion if (picker.value != activeMajor) picker.value = activeMajor }, modifier = Modifier @@ -206,8 +279,9 @@ private fun VersionSelectorSheet( .testTag("release_version_major_picker_${appName.lowercase()}"), ) } + // Start-aligned so the radio buttons line up regardless of each version's length. Column( - horizontalAlignment = Alignment.CenterHorizontally, + horizontalAlignment = Alignment.Start, modifier = Modifier .weight(1f) .padding(start = 24.dp), @@ -218,7 +292,14 @@ private fun VersionSelectorSheet( fontWeight = FontWeight.Bold, modifier = Modifier.padding(bottom = 8.dp), ) - if (variants.isEmpty()) { + if (variants.isEmpty() && isLoadingCandidates) { + CircularProgressIndicator( + modifier = Modifier + .padding(vertical = 16.dp) + .size(24.dp) + .testTag("release_version_candidates_loading_${appName.lowercase()}"), + ) + } else if (variants.isEmpty()) { Box(modifier = Modifier.fillMaxWidth()) { Text( text = if (isActiveMajorSelectable) { @@ -237,16 +318,17 @@ private fun VersionSelectorSheet( } } else { LazyColumn( + state = buildsListState, modifier = Modifier .weight(1f, fill = false) - .wrapContentWidth(), + .fillMaxWidth(), ) { items(variants, key = { it }) { version -> val selected = draftVersion == version Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .wrapContentWidth() + .fillMaxWidth() .clickable { draftVersion = version onConfirm(version) @@ -271,6 +353,14 @@ private fun VersionSelectorSheet( } } } + if (isLoadingCandidates) { + CircularProgressIndicator( + modifier = Modifier + .padding(vertical = 8.dp) + .size(24.dp) + .testTag("release_version_candidates_loading_${appName.lowercase()}"), + ) + } } } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt index 60a7296..8e8c1b9 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt @@ -26,6 +26,19 @@ data class NightlyBuildOption( val label: String, // date + time for display, e.g. "2026-07-24 09:17" ) +/** + * Release-candidate state for the version picker. The `candidates/` listing ([baseVersions]) is + * fetched with the home load so majors with only candidate builds can be selected (the picker + * retries if that failed); RC build numbers are fetched per major as the user browses to it and + * merged into [AppUiModel.availableReleaseVersions]. + */ +data class ReleaseCandidatesUiState( + val baseVersions: List = emptyList(), + val isBaseVersionsLoaded: Boolean = false, + val loadedMajors: Set = emptySet(), + val loadingMajors: Set = emptySet(), +) + data class AppUiModel( val name: String, val packageName: String, @@ -40,6 +53,8 @@ data class AppUiModel( val userPickedDate: LocalDate? = null, val selectedReleaseVersion: String? = null, val availableReleaseVersions: List = emptyList(), + // RC builds are loaded lazily by the version picker; only the candidates listing comes with the home load. + val releaseCandidates: ReleaseCandidatesUiState = ReleaseCandidatesUiState(), // When a picked date has multiple builds, these drive a one-shot picker prompt. Empty otherwise. val pendingBuildOptions: List = emptyList(), ) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt index c7f2f39..4e9ba03 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -67,6 +67,7 @@ import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.ui.models.NightlyBuildOption +import org.mozilla.tryfox.ui.models.ReleaseCandidatesUiState import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG @@ -91,6 +92,7 @@ internal fun HomeAppCard( onDateSelected: (String, LocalDate) -> Unit, dateValidator: (LocalDate) -> Boolean, onReleaseVersionSelected: (String, String) -> Unit, + onReleaseMajorBrowsed: (String, Int) -> Unit, onBuildSelected: (String, String) -> Unit, onDismissBuildPicker: (String) -> Unit, modifier: Modifier = Modifier, @@ -201,7 +203,9 @@ internal fun HomeAppCard( appName = app.name, selectedVersion = app.selectedReleaseVersion ?: selectedApk.version, versions = app.availableReleaseVersions, + releaseCandidates = app.releaseCandidates, onSelected = onReleaseVersionSelected, + onMajorBrowsed = onReleaseMajorBrowsed, ) selectedApk != null -> Text(selectedApk.version, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) else -> Text(stringResource(R.string.home_no_apks_available), style = MaterialTheme.typography.bodyMedium) @@ -322,12 +326,21 @@ internal fun datePickerSelectionDate(selectionMillis: Long): LocalDate = Instant.fromEpochMilliseconds(selectionMillis).toLocalDateTime(TimeZone.UTC).date @Composable -private fun ReleaseVersionDetails(appName: String, selectedVersion: String, versions: List, onSelected: (String, String) -> Unit) { +private fun ReleaseVersionDetails( + appName: String, + selectedVersion: String, + versions: List, + releaseCandidates: ReleaseCandidatesUiState, + onSelected: (String, String) -> Unit, + onMajorBrowsed: (String, Int) -> Unit, +) { VersionSelector( appName = appName, selectedReleaseVersion = selectedVersion, availableReleaseVersions = versions, onReleaseVersionSelected = { version -> onSelected(appName, version) }, + releaseCandidates = releaseCandidates, + onReleaseMajorBrowsed = { major -> onMajorBrowsed(appName, major) }, ) } 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 09b2ab9..094d510 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 @@ -215,6 +215,9 @@ fun HomeScreen( onReleaseVersionSelected = { appName, version -> homeViewModel.onReleaseVersionSelected(appName, version) }, + onReleaseMajorBrowsed = { appName, major -> + homeViewModel.onReleaseMajorBrowsed(appName, major) + }, onBuildSelected = { appName, buildId -> homeViewModel.onNightlyBuildSelected(appName, buildId) }, 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 091978a..33e7528 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt @@ -4,6 +4,9 @@ import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow @@ -19,6 +22,7 @@ import kotlinx.datetime.TimeZone import kotlinx.datetime.todayIn import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.data.InstalledTryBuild +import org.mozilla.tryfox.data.MozillaArchiveHtmlParser import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.managers.CacheManager @@ -50,6 +54,7 @@ import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.ui.models.NightlyBuildOption +import org.mozilla.tryfox.ui.models.ReleaseCandidatesUiState import org.mozilla.tryfox.ui.models.newVersionAvailable import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA @@ -63,6 +68,8 @@ import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TRYFOX import org.mozilla.tryfox.util.abiPreferenceOrder import org.mozilla.tryfox.util.isAbiSupported +import org.mozilla.tryfox.util.isReleaseCandidateVersion +import org.mozilla.tryfox.util.releaseVersionMajor import java.io.File /** @@ -115,6 +122,8 @@ class HomeViewModel( private val refreshMutex = Mutex() private val refreshStateMutex = Mutex() private var activeRefreshes = 0 + private val releaseCandidatesMutex = Mutex() + private val versionParser = MozillaArchiveHtmlParser() init { downloadCoordinator.downloads @@ -320,8 +329,11 @@ class HomeViewModel( publishCurrentApps() } - val fetchedApps = releaseRepositories.associate { repository -> - repository.appName to buildAppUiModel(repository, appInfoMap[repository.appName]) + // Load every card concurrently; cards that read the same archive listing share one request. + val fetchedApps = coroutineScope { + releaseRepositories.map { repository -> + async { repository.appName to buildAppUiModel(repository, appInfoMap[repository.appName]) } + }.awaitAll().toMap() } applyFetchedApps(fetchedApps, mutationVersionsAtStart) publishCurrentApps() @@ -473,11 +485,23 @@ class HomeViewModel( repository: ReleaseRepository, appState: AppState?, ): AppUiModel { + var releaseCandidates = ReleaseCandidatesUiState() val (apksResult, selectedReleaseVersion, availableReleaseVersions) = if (repository is VersionAwareReleaseRepository) { - when (val versionsResult = repository.getAvailableReleaseVersions()) { + // The top-level candidates listing is one request, like the releases listing; the + // per-version candidate directories are only fetched on demand by the picker. + val (versionsResult, candidateBasesResult) = coroutineScope { + val versions = async { repository.getAvailableReleaseVersions() } + val candidateBases = async { repository.getCandidateBaseVersions() } + versions.await() to candidateBases.await() + } + releaseCandidates = (candidateBasesResult as? NetworkResult.Success) + ?.let { ReleaseCandidatesUiState(baseVersions = it.data, isBaseVersionsLoaded = true) } + ?: ReleaseCandidatesUiState() + when (versionsResult) { is NetworkResult.Success -> { - val selectedVersion = versionsResult.data.firstOrNull() + // Only recommend published builds; RCs are reachable through the picker. + val selectedVersion = versionsResult.data.firstOrNull { !isReleaseCandidateVersion(it) } val releaseResult = if (selectedVersion != null) { repository.getReleasesForVersion(selectedVersion) } else { @@ -518,6 +542,7 @@ class HomeViewModel( apks = apksResult, selectedReleaseVersion = selectedReleaseVersion, availableReleaseVersions = availableReleaseVersions, + releaseCandidates = releaseCandidates, ) } @@ -721,6 +746,68 @@ class HomeViewModel( } } + /** + * Called by the version picker when it shows [major]. Loads the RC builds for that major and + * merges them into the picker's versions, re-fetching the candidates listing if the home load + * couldn't get it. + */ + fun onReleaseMajorBrowsed(appName: String, major: Int) { + val repository = + releaseRepositories.firstOrNull { it.appName == appName } as? VersionAwareReleaseRepository + ?: return + + viewModelScope.launch(ioDispatcher) { + val claimed = releaseCandidatesMutex.withLock { + val candidates = synchronized(appsLock) { currentAppsByName[appName] }?.releaseCandidates + ?: return@withLock false + if (major in candidates.loadedMajors || major in candidates.loadingMajors) return@withLock false + updateAppEverywhere(appName) { + it.copy(releaseCandidates = it.releaseCandidates.copy(loadingMajors = it.releaseCandidates.loadingMajors + major)) + } + true + } + if (!claimed) return@launch + + val baseVersions = releaseCandidatesMutex.withLock { loadCandidateBaseVersions(appName, repository) } + val results = baseVersions.orEmpty() + .filter { releaseVersionMajor(it) == major } + .let { bases -> coroutineScope { bases.map { async { repository.getCandidateVersions(it) } }.awaitAll() } } + val rcVersions = results.flatMap { (it as? NetworkResult.Success)?.data.orEmpty() } + // Leave the major unloaded on failure so browsing to it again retries. + val succeeded = baseVersions != null && results.all { it is NetworkResult.Success } + + releaseCandidatesMutex.withLock { + updateAppEverywhere(appName) { app -> + val candidates = app.releaseCandidates + app.copy( + availableReleaseVersions = (app.availableReleaseVersions + rcVersions) + .distinct() + .sortedWith(versionParser::compareReleaseVersions) + .reversed(), + releaseCandidates = candidates.copy( + loadedMajors = if (succeeded) candidates.loadedMajors + major else candidates.loadedMajors, + loadingMajors = candidates.loadingMajors - major, + ), + ) + } + } + } + } + + /** Returns the candidate base versions, fetching the listing on first use. Null if it failed. */ + private suspend fun loadCandidateBaseVersions( + appName: String, + repository: VersionAwareReleaseRepository, + ): List? { + val candidates = synchronized(appsLock) { currentAppsByName[appName] }?.releaseCandidates ?: return null + if (candidates.isBaseVersionsLoaded) return candidates.baseVersions + val result = repository.getCandidateBaseVersions() as? NetworkResult.Success ?: return null + updateAppEverywhere(appName) { + it.copy(releaseCandidates = it.releaseCandidates.copy(baseVersions = result.data, isBaseVersionsLoaded = true)) + } + return result.data + } + private fun updateAppEverywhere(appName: String, transform: (AppUiModel) -> AppUiModel) { updateCurrentApp(appName, transform) _homeScreenState.update { state -> diff --git a/app/src/main/java/org/mozilla/tryfox/util/Version.kt b/app/src/main/java/org/mozilla/tryfox/util/Version.kt index fc8b2b0..a9a3c7d 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Version.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Version.kt @@ -2,6 +2,16 @@ package org.mozilla.tryfox.util import java.util.regex.Pattern +private val RELEASE_MAJOR_REGEX = Regex("^(\\d+)(?:\\.|$)") +private val RELEASE_CANDIDATE_SUFFIX = Regex("-RC\\d+$") + +/** Major version of an archive release string such as `157.0.1`, `158.0b4` or `157.0-RC2`. */ +fun releaseVersionMajor(version: String): Int? = + RELEASE_MAJOR_REGEX.find(version)?.groupValues?.getOrNull(1)?.toIntOrNull() + +/** True for a release candidate build such as `157.0-RC2`, as opposed to a published release. */ +fun isReleaseCandidateVersion(version: String): Boolean = RELEASE_CANDIDATE_SUFFIX.containsMatchIn(version) + data class Version( val fullStringVersion: String, val major: Int, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index b19dd6b..4bd91de 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,6 +111,7 @@ Choose Release version Choose Beta version Select or type a version, then choose the exact build. + Show release candidates Version Major version Previous major version diff --git a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt index 494c7d3..0c5e419 100644 --- a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt @@ -1,17 +1,28 @@ package org.mozilla.tryfox +import kotlinx.coroutines.CompletableDeferred +import kotlinx.coroutines.async import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withTimeout +import kotlinx.coroutines.yield +import kotlinx.datetime.Clock +import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test import org.mockito.kotlin.mock +import org.mockito.kotlin.times +import org.mockito.kotlin.verify +import org.mockito.kotlin.verifyNoMoreInteractions import org.mockito.kotlin.whenever import org.mozilla.tryfox.data.MozillaArchiveHtmlParser import org.mozilla.tryfox.data.NetworkResult import org.mozilla.tryfox.data.ReleaseType import org.mozilla.tryfox.data.repositories.DefaultMozillaArchiveRepository import org.mozilla.tryfox.network.MozillaArchivesApiService +import retrofit2.Response +import kotlin.time.Duration.Companion.seconds class FenixReleaseTest { @@ -456,68 +467,158 @@ class FenixReleaseTest { } @Test - fun `Fenix release versions include candidates for published and unpublished bases`() = runBlocking { + fun `Fenix release versions list only published releases without fetching candidates`() = runBlocking { val api: MozillaArchivesApiService = mock() val releasesHtml = """ 153.0.4/ + 153.0.3/ 153.0b4/ """.trimIndent() - val candidatesHtml = loadHtmlResource("fenix-candidates-page.html") - val buildsHtml = loadHtmlResource("fenix-candidate-builds.html") whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL)).thenReturn(releasesHtml) - whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.CANDIDATES_FENIX_BASE_URL)).thenReturn(candidatesHtml) - whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForCandidateBuilds("153.0.4"))).thenReturn(buildsHtml) - whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForCandidateBuilds("153.0.5"))).thenReturn(buildsHtml) - whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForCandidateBuilds("153.0b4"))).thenReturn(buildsHtml) - whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForCandidateBuilds("153.0b5"))).thenReturn(buildsHtml) val repository = DefaultMozillaArchiveRepository(api) assertEquals( - listOf("153.0.5-RC2", "153.0.5-RC1", "153.0.4", "153.0.4-RC2", "153.0.4-RC1"), + listOf("153.0.4", "153.0.3"), (repository.getFenixReleaseVersions(ReleaseType.Release) as NetworkResult.Success).data, ) assertEquals( - listOf("153.0b5-RC2", "153.0b5-RC1", "153.0b4", "153.0b4-RC2", "153.0b4-RC1"), + listOf("153.0b4"), (repository.getFenixReleaseVersions(ReleaseType.Beta) as NetworkResult.Success).data, ) + // Release and Beta share one fetch of the listing. + verify(api, times(1)).getHtmlPage(DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL) + verifyNoMoreInteractions(api) } @Test - fun `Fenix release versions merge published and candidate builds in semantic order`() = runBlocking { + fun `Fenix candidate base versions for both channels share a single candidates listing`() = runBlocking { val api: MozillaArchivesApiService = mock() - val releasesHtml = """ - 153.0.4/ - 153.0.3/ - """.trimIndent() - val candidatesHtml = "153.0.4-candidates/" - val buildsHtml = """ - build1/ - build2/ - """.trimIndent() - whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL)).thenReturn(releasesHtml) + val candidatesHtml = loadHtmlResource("fenix-candidates-page.html") whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.CANDIDATES_FENIX_BASE_URL)).thenReturn(candidatesHtml) - whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForCandidateBuilds("153.0.4"))).thenReturn(buildsHtml) - val result = DefaultMozillaArchiveRepository(api).getFenixReleaseVersions(ReleaseType.Release) + val repository = DefaultMozillaArchiveRepository(api) assertEquals( - listOf("153.0.4", "153.0.4-RC2", "153.0.4-RC1", "153.0.3"), - (result as NetworkResult.Success).data, + listOf("153.0.5", "153.0.4"), + (repository.getFenixCandidateBaseVersions(ReleaseType.Release) as NetworkResult.Success).data, + ) + assertEquals( + listOf("153.0b5", "153.0b4"), + (repository.getFenixCandidateBaseVersions(ReleaseType.Beta) as NetworkResult.Success).data, ) + verify(api, times(1)).getHtmlPage(DefaultMozillaArchiveRepository.CANDIDATES_FENIX_BASE_URL) + verifyNoMoreInteractions(api) } @Test - fun `Fenix release versions fall back to published versions when candidates cannot load`() = runBlocking { + fun `archive listings are fetched again once the cache expires`() = runBlocking { + val api: MozillaArchivesApiService = mock() + whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL)) + .thenReturn("153.0.4/") + .thenReturn("153.0.5/") + val clock = MutableClock(Instant.parse("2026-10-06T12:00:00Z")) + val repository = DefaultMozillaArchiveRepository(api, clock) + + assertEquals(listOf("153.0.4"), (repository.getFenixReleaseVersions(ReleaseType.Release) as NetworkResult.Success).data) + clock.now += 5.seconds + assertEquals(listOf("153.0.4"), (repository.getFenixReleaseVersions(ReleaseType.Release) as NetworkResult.Success).data) + clock.now += 10.seconds + assertEquals(listOf("153.0.5"), (repository.getFenixReleaseVersions(ReleaseType.Release) as NetworkResult.Success).data) + verify(api, times(2)).getHtmlPage(DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL) + } + + @Test + fun `failed archive listings are not cached`() = runBlocking { + val api: MozillaArchivesApiService = mock() + whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.CANDIDATES_FENIX_BASE_URL)) + .thenThrow(IllegalStateException("Candidates unavailable")) + .thenReturn(loadHtmlResource("fenix-candidates-page.html")) + val repository = DefaultMozillaArchiveRepository(api) + + assertTrue(repository.getFenixCandidateBaseVersions(ReleaseType.Release) is NetworkResult.Error) + assertEquals( + listOf("153.0.5", "153.0.4"), + (repository.getFenixCandidateBaseVersions(ReleaseType.Release) as NetworkResult.Success).data, + ) + } + + private class MutableClock(var now: Instant) : Clock { + override fun now(): Instant = now + } + + @Test + fun `concurrent requests for the same listing share one fetch`() = runBlocking { + val releasesUrl = DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL + val api = GatedArchivesApiService(mapOf(releasesUrl to "153.0.4/153.0b4/")) + val repository = DefaultMozillaArchiveRepository(api) + + val release = async { repository.getFenixReleaseVersions(ReleaseType.Release) } + val beta = async { repository.getFenixReleaseVersions(ReleaseType.Beta) } + + assertEquals(listOf("153.0.4"), (release.await() as NetworkResult.Success).data) + assertEquals(listOf("153.0b4"), (beta.await() as NetworkResult.Success).data) + assertEquals(listOf(releasesUrl), api.requestedUrls) + } + + @Test + fun `listings for different URLs are fetched in parallel`() = runBlocking { + val fenixUrl = DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL + val focusUrl = DefaultMozillaArchiveRepository.RELEASES_FOCUS_BASE_URL + val focusStarted = CompletableDeferred() + val api = GatedArchivesApiService( + pages = mapOf(fenixUrl to "153.0.4/", focusUrl to "153.0.3/"), + // The Fenix fetch only completes once the Focus fetch has started, which deadlocks if + // listings are fetched one at a time. + beforeResponse = mapOf(fenixUrl to { focusStarted.await() }, focusUrl to { focusStarted.complete(Unit) }), + ) + val repository = DefaultMozillaArchiveRepository(api) + + withTimeout(5.seconds) { + val fenix = async { repository.getFenixReleaseVersions(ReleaseType.Release) } + val focus = async { repository.getFocusReleaseVersions() } + assertEquals(listOf("153.0.4"), (fenix.await() as NetworkResult.Success).data) + assertEquals(listOf("153.0.3"), (focus.await() as NetworkResult.Success).data) + } + } + + /** Serves fixed pages, optionally suspending in [beforeResponse] first, and records requests. */ + private class GatedArchivesApiService( + private val pages: Map, + private val beforeResponse: Map Unit> = emptyMap(), + ) : MozillaArchivesApiService { + val requestedUrls = mutableListOf() + + override suspend fun getHtmlPage(url: String): String { + requestedUrls += url + yield() + beforeResponse[url]?.invoke() + return pages.getValue(url) + } + + override suspend fun head(url: String): Response = error("Not used") + } + + @Test + fun `Fenix candidate versions list RC builds for one base version`() = runBlocking { + val api: MozillaArchivesApiService = mock() + val buildsHtml = loadHtmlResource("fenix-candidate-builds.html") + whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForCandidateBuilds("153.0.5"))).thenReturn(buildsHtml) + + val result = DefaultMozillaArchiveRepository(api).getFenixCandidateVersions("153.0.5") + + assertEquals(listOf("153.0.5-RC2", "153.0.5-RC1"), (result as NetworkResult.Success).data) + } + + @Test + fun `Fenix candidate base versions report an error when the listing cannot load`() = runBlocking { val api: MozillaArchivesApiService = mock() - val releasesHtml = "153.0.4/" - whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL)).thenReturn(releasesHtml) whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.CANDIDATES_FENIX_BASE_URL)) .thenThrow(IllegalStateException("Candidates unavailable")) - val result = DefaultMozillaArchiveRepository(api).getFenixReleaseVersions(ReleaseType.Release) + val result = DefaultMozillaArchiveRepository(api).getFenixCandidateBaseVersions(ReleaseType.Release) - assertEquals(listOf("153.0.4"), (result as NetworkResult.Success).data) + assertTrue(result is NetworkResult.Error) } @Test diff --git a/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt index eb3b9d8..a913e90 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt @@ -13,6 +13,8 @@ class FakeMozillaArchiveRepository( private val fenixReleases: NetworkResult> = NetworkResult.Success(emptyList()), private val fenixReleaseVersions: NetworkResult> = NetworkResult.Success(emptyList()), private val fenixReleasesByVersion: Map>> = emptyMap(), + private val fenixCandidateBaseVersions: NetworkResult> = NetworkResult.Success(emptyList()), + private val fenixCandidateVersionsByBase: Map>> = emptyMap(), private val focusReleases: NetworkResult> = NetworkResult.Success(emptyList()), private val focusReleaseVersions: NetworkResult> = NetworkResult.Success(emptyList()), private val focusReleasesByVersion: Map>> = emptyMap(), @@ -39,6 +41,14 @@ class FakeMozillaArchiveRepository( return fenixReleaseVersions } + override suspend fun getFenixCandidateBaseVersions(releaseType: ReleaseType): NetworkResult> { + return fenixCandidateBaseVersions + } + + override suspend fun getFenixCandidateVersions(baseVersion: String): NetworkResult> { + return fenixCandidateVersionsByBase[baseVersion] ?: NetworkResult.Success(emptyList()) + } + override suspend fun getFenixReleaseBuildsForVersion( version: String, releaseType: ReleaseType, 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 229c988..d689f1a 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 @@ -35,6 +35,7 @@ import org.mozilla.tryfox.data.FakeTryFoxReleaseRepository import org.mozilla.tryfox.data.InstalledTryBuild import org.mozilla.tryfox.data.MozillaPackageManager import org.mozilla.tryfox.data.NetworkResult +import org.mozilla.tryfox.data.ReleaseType import org.mozilla.tryfox.data.managers.FakeCacheManager import org.mozilla.tryfox.data.managers.FakeIntentManager import org.mozilla.tryfox.data.managers.FakeUserDataRepository @@ -994,6 +995,118 @@ class HomeViewModelTest { private fun fenixReleaseApp() = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps[FENIX_RELEASE]!! + @Test + fun `home load fetches only the candidates listing and RC builds load per browsed major`() = runTest { + val stableApk = createTestParsedReleaseApk(version = "157.0.1") + val archive = CountingCandidatesArchiveRepository( + FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("157.0.1", "156.0")), + fenixReleasesByVersion = mapOf("157.0.1" to NetworkResult.Success(listOf(stableApk))), + // 158.0 only has candidates so far; it must still be reachable in the picker. + fenixCandidateBaseVersions = NetworkResult.Success(listOf("158.0", "157.0.1", "156.0")), + fenixCandidateVersionsByBase = mapOf( + "158.0" to NetworkResult.Success(listOf("158.0-RC2", "158.0-RC1")), + "157.0.1" to NetworkResult.Success(listOf("157.0.1-RC1")), + "156.0" to NetworkResult.Success(listOf("156.0-RC1")), + ), + ), + ) + viewModel = createViewModel(releaseRepositories = listOf(FenixReleaseReleaseRepository(archive))) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + + viewModel.initialLoad() + advanceUntilIdle() + + fun app() = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps[FENIX_RELEASE]!! + assertEquals(1, archive.baseVersionRequests) + assertEquals(emptyList(), archive.candidateRequests) + assertEquals(listOf("158.0", "157.0.1", "156.0"), app().releaseCandidates.baseVersions) + assertEquals("157.0.1", app().selectedReleaseVersion) + assertEquals(listOf("157.0.1", "156.0"), app().availableReleaseVersions) + + viewModel.onReleaseMajorBrowsed(FENIX_RELEASE, 157) + advanceUntilIdle() + + assertEquals(listOf("157.0.1"), archive.candidateRequests) + assertEquals(listOf("157.0.1", "157.0.1-RC1", "156.0"), app().availableReleaseVersions) + + viewModel.onReleaseMajorBrowsed(FENIX_RELEASE, 158) + viewModel.onReleaseMajorBrowsed(FENIX_RELEASE, 157) + advanceUntilIdle() + + assertEquals(1, archive.baseVersionRequests) + assertEquals(listOf("157.0.1", "158.0"), archive.candidateRequests) + assertEquals( + listOf("158.0-RC2", "158.0-RC1", "157.0.1", "157.0.1-RC1", "156.0"), + app().availableReleaseVersions, + ) + assertEquals(setOf(157, 158), app().releaseCandidates.loadedMajors) + assertTrue(app().releaseCandidates.loadingMajors.isEmpty()) + } + + @Test + fun `release candidate major is retried after a failed load`() = runTest { + val stableApk = createTestParsedReleaseApk(version = "157.0.1") + val archive = CountingCandidatesArchiveRepository( + FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("157.0.1")), + fenixReleasesByVersion = mapOf("157.0.1" to NetworkResult.Success(listOf(stableApk))), + fenixCandidateBaseVersions = NetworkResult.Error("offline", null), + ), + ) + viewModel = createViewModel(releaseRepositories = listOf(FenixReleaseReleaseRepository(archive))) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + + viewModel.initialLoad() + advanceUntilIdle() + viewModel.onReleaseMajorBrowsed(FENIX_RELEASE, 157) + advanceUntilIdle() + viewModel.onReleaseMajorBrowsed(FENIX_RELEASE, 157) + advanceUntilIdle() + + val app = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps[FENIX_RELEASE]!! + // One attempt on the home load, then one per browse since each failed. + assertEquals(3, archive.baseVersionRequests) + assertTrue(app.releaseCandidates.loadedMajors.isEmpty()) + assertTrue(app.releaseCandidates.loadingMajors.isEmpty()) + assertEquals(listOf("157.0.1"), app.availableReleaseVersions) + } + + @Test + fun `home load never recommends a release candidate`() = runTest { + val betaApk = createTestParsedReleaseApk(version = "158.0b4") + val archive = FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("158.0b5-RC1", "158.0b4", "158.0b4-RC1")), + fenixReleasesByVersion = mapOf("158.0b4" to NetworkResult.Success(listOf(betaApk))), + ) + viewModel = createViewModel(releaseRepositories = listOf(FenixReleaseReleaseRepository(archive))) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + + viewModel.initialLoad() + advanceUntilIdle() + + val app = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps[FENIX_RELEASE]!! + assertEquals("158.0b4", app.selectedReleaseVersion) + assertEquals("158.0b4", (app.apks as ApksResult.Success).apks.single().version) + } + + private class CountingCandidatesArchiveRepository( + private val delegate: FakeMozillaArchiveRepository, + ) : MozillaArchiveRepository by delegate { + var baseVersionRequests = 0 + val candidateRequests = mutableListOf() + + override suspend fun getFenixCandidateBaseVersions(releaseType: ReleaseType): NetworkResult> { + baseVersionRequests++ + return delegate.getFenixCandidateBaseVersions(releaseType) + } + + override suspend fun getFenixCandidateVersions(baseVersion: String): NetworkResult> { + candidateRequests += baseVersion + return delegate.getFenixCandidateVersions(baseVersion) + } + } + @Test fun `onReleaseVersionSelected should reload Focus APKs for selected version`() = runTest { val latestReleaseApk = createTestParsedReleaseApk(version = "147.0.1", appName = testFocusReleaseAppName)