From 996d8dc6f43b5918ee41f6788bc1eb7f11353a59 Mon Sep 17 00:00:00 2001 From: Ted Campbell Date: Thu, 8 Oct 2026 00:20:03 -0400 Subject: [PATCH 1/4] Only scrape releases pages once for both release and beta cards The same archive.mozilla.org folder has data for both channels so avoid the extra network requests. --- .../DefaultMozillaArchiveRepository.kt | 47 ++++++--- .../tryfox/ui/screens/HomeViewModel.kt | 10 +- .../org/mozilla/tryfox/FenixReleaseTest.kt | 96 +++++++++++++++++++ 3 files changed, 140 insertions(+), 13 deletions(-) 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..5e55c9c 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 @@ -4,8 +4,11 @@ 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 +26,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,7 +35,11 @@ class DefaultMozillaArchiveRepository( private val mozillaArchiveHtmlParser: MozillaArchiveHtmlParser = MozillaArchiveHtmlParser(), ) : MozillaArchiveRepository { + private val listingLocks = ConcurrentHashMap() + private val listingCache = ConcurrentHashMap() + companion object { + private val LISTING_CACHE_TTL = 10.seconds private const val CANDIDATE_BUILD_INDEX_CONCURRENCY = 4 const val ARCHIVE_MOZILLA_BASE_URL = "https://archive.mozilla.org/pub/" const val RELEASES_FENIX_BASE_URL = "${ARCHIVE_MOZILLA_BASE_URL}fenix/releases/" @@ -67,8 +76,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 +107,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,8 +130,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 releaseVersions = mozillaArchiveHtmlParser + .releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FENIX_BASE_URL), releaseType) val candidateVersions = fetchFenixCandidateVersions(releaseType) val versions = (releaseVersions + candidateVersions) .distinct() @@ -150,8 +160,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) @@ -296,6 +306,21 @@ class DefaultMozillaArchiveRepository( private data class CandidateVersion(val baseVersion: String, val buildNumber: Int) + 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 suspend fun getHtmlPageOrNull(url: String): String? = try { mozillaArchivesApiService.getHtmlPage(url) } catch (e: CancellationException) { 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..9428af1 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 @@ -320,8 +323,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() diff --git a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt index 494c7d3..08156f0 100644 --- a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt @@ -1,17 +1,27 @@ 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.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 { @@ -565,6 +575,92 @@ class FenixReleaseTest { ) } + @Test + 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.RELEASES_FENIX_BASE_URL)) + .thenThrow(IllegalStateException("Releases unavailable")) + .thenReturn("153.0.4/") + val repository = DefaultMozillaArchiveRepository(api) + + assertTrue(repository.getFenixReleaseVersions(ReleaseType.Release) is NetworkResult.Error) + assertEquals(listOf("153.0.4"), (repository.getFenixReleaseVersions(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) + // Loading release versions also fetches the candidates listing, so count releases fetches. + assertEquals(1, api.requestedUrls.count { it == releasesUrl }) + } + + @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") + } + // Helper method private fun abisIn(html: String, appName: String): List = From 4a43432d0ff345725ab3d9351c0e37c1f650592b Mon Sep 17 00:00:00 2001 From: Ted Campbell Date: Tue, 6 Oct 2026 22:07:40 -0400 Subject: [PATCH 2/4] Do not recommend release candidates on the home screen Using RC builds is a special case so lets not make it the default recommendation and instead rely on version selector screen. --- .../mozilla/tryfox/ui/screens/HomeViewModel.kt | 6 +++++- .../tryfox/ui/screens/HomeViewModelTest.kt | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) 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 9428af1..47668f8 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 @@ -483,7 +483,8 @@ class HomeViewModel( if (repository is VersionAwareReleaseRepository) { when (val versionsResult = repository.getAvailableReleaseVersions()) { is NetworkResult.Success -> { - val selectedVersion = versionsResult.data.firstOrNull() + // Only recommend published builds; RCs are reachable through the picker. + val selectedVersion = versionsResult.data.firstOrNull { !it.isReleaseCandidate() } val releaseResult = if (selectedVersion != null) { repository.getReleasesForVersion(selectedVersion) } else { @@ -551,6 +552,8 @@ class HomeViewModel( private suspend fun selectPublishedApk(apks: List): ApkUiModel? = candidatesByPreference(apks).firstOrNull { mozillaArchiveRepository.isPublished(it.url) } + private fun String.isReleaseCandidate(): Boolean = RELEASE_CANDIDATE_SUFFIX.containsMatchIn(this) + private fun convertParsedApksToUiModels(parsedApks: List): List { return parsedApks.map { parsedApk -> val buildDate = parsedApk.rawDateString?.rawNightlyBuildDate() @@ -940,5 +943,6 @@ class HomeViewModel( companion object { private const val TAG = "HomeViewModel" + private val RELEASE_CANDIDATE_SUFFIX = Regex("-RC\\d+$") } } 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..8aac988 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 @@ -994,6 +994,24 @@ class HomeViewModelTest { private fun fenixReleaseApp() = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps[FENIX_RELEASE]!! + @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) + } + @Test fun `onReleaseVersionSelected should reload Focus APKs for selected version`() = runTest { val latestReleaseApk = createTestParsedReleaseApk(version = "147.0.1", appName = testFocusReleaseAppName) From 0c28ff8890f994d04f823f358a80e14145b63e5f Mon Sep 17 00:00:00 2001 From: Ted Campbell Date: Tue, 6 Oct 2026 22:07:59 -0400 Subject: [PATCH 3/4] Load Fenix release candidates on demand in the version picker Scraping every RC is taking 300+ network requests on startup for a rarely used feature so instead on fetch on-deman in the version selector. --- .../tryfox/ui/screens/HomeAppCardTest.kt | 4 + .../DefaultMozillaArchiveRepository.kt | 69 +++---- .../FenixBetaReleaseRepository.kt | 8 + .../FenixReleaseReleaseRepository.kt | 8 + .../repositories/MozillaArchiveRepository.kt | 9 + .../data/repositories/ReleaseRepository.kt | 6 + .../tryfox/ui/composables/VersionSelector.kt | 58 ++++-- .../mozilla/tryfox/ui/models/AppUiModel.kt | 15 ++ .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 15 +- .../mozilla/tryfox/ui/screens/HomeScreen.kt | 3 + .../tryfox/ui/screens/HomeViewModel.kt | 81 +++++++- .../java/org/mozilla/tryfox/util/Version.kt | 6 + .../org/mozilla/tryfox/FenixReleaseTest.kt | 181 +++++++++--------- .../data/FakeMozillaArchiveRepository.kt | 10 + .../tryfox/ui/screens/HomeViewModelTest.kt | 95 +++++++++ 15 files changed, 426 insertions(+), 142 deletions(-) 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 5e55c9c..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,9 +1,6 @@ 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 @@ -40,7 +37,6 @@ class DefaultMozillaArchiveRepository( companion object { private val LISTING_CACHE_TTL = 10.seconds - private const val CANDIDATE_BUILD_INDEX_CONCURRENCY = 4 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/" @@ -130,13 +126,8 @@ class DefaultMozillaArchiveRepository( override suspend fun getFenixReleaseVersions(releaseType: ReleaseType): NetworkResult> { return try { - val releaseVersions = mozillaArchiveHtmlParser + val versions = mozillaArchiveHtmlParser .releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FENIX_BASE_URL), releaseType) - val candidateVersions = fetchFenixCandidateVersions(releaseType) - val versions = (releaseVersions + candidateVersions) - .distinct() - .sortedWith(mozillaArchiveHtmlParser::compareReleaseVersions) - .reversed() if (versions.isEmpty()) { return NetworkResult.Error("No release versions found for type $releaseType", null) @@ -150,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) } @@ -271,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) || @@ -321,14 +316,6 @@ class DefaultMozillaArchiveRepository( .also { listingCache[url] = CachedListing(now, it) } } - private suspend fun getHtmlPageOrNull(url: String): String? = try { - mozillaArchivesApiService.getHtmlPage(url) - } catch (e: CancellationException) { - throw e - } catch (_: Exception) { - null - } - private fun parseCandidateVersion(version: String): CandidateVersion? { val match = Regex("^(\\d+\\.\\d+(?:\\.\\d+)?|\\d+\\.\\d+b\\d+)-RC(\\d+)$").matchEntire(version) ?: return null return CandidateVersion(match.groupValues[1], match.groupValues[2].toInt()) 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..6b44499 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,14 @@ 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.material.icons.Icons import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.ArrowDropUp +import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme @@ -32,9 +34,11 @@ 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.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @@ -49,8 +53,10 @@ 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.releaseVersionMajor @Composable internal fun VersionSelector( @@ -58,6 +64,8 @@ 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() @@ -88,6 +96,8 @@ internal fun VersionSelector( appName = appName, selectedVersion = selectedVersion, availableVersions = availableReleaseVersions, + releaseCandidates = releaseCandidates, + onMajorBrowsed = onReleaseMajorBrowsed, onDismiss = { showSelector = false }, onConfirm = { version -> showSelector = false @@ -100,29 +110,39 @@ 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, onDismiss: () -> Unit, onConfirm: (String) -> Unit, ) { val versionsByMajor = remember(availableVersions) { - availableVersions.mapNotNull { version -> versionMajor(version)?.let { it to version } }.groupBy({ it.first }, { it.second }) + availableVersions.mapNotNull { version -> releaseVersionMajor(version)?.let { it to version } }.groupBy({ it.first }, { it.second }) + } + // Majors that only have candidate builds so far must still be reachable on the wheel. + val knownMajors = remember(versionsByMajor, releaseCandidates.baseVersions) { + versionsByMajor.keys + releaseCandidates.baseVersions.mapNotNull(::releaseVersionMajor) } - val initialMajor = versionMajor(selectedVersion.orEmpty()) + val initialMajor = releaseVersionMajor(selectedVersion.orEmpty()) ?: versionsByMajor.keys.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() + val isLoadingCandidates = activeMajor in releaseCandidates.loadingMajors + // The NumberPicker's text listener outlives recompositions, so read the latest bound through state. + val currentMaximumMajor by rememberUpdatedState(maximumSupportedMajorVersion) + + LaunchedEffect(activeMajor) { + if (isActiveMajorSelectable) onMajorBrowsed(activeMajor) + } val title = if (appName == FENIX_BETA || appName == FOCUS_BETA) { stringResource(R.string.version_selector_beta_title) } else { @@ -132,7 +152,7 @@ private fun VersionSelectorSheet( fun selectMajor(major: Int) { activeMajor = major - draftVersion = draftVersion?.takeIf { versionMajor(it) == major } + draftVersion = draftVersion?.takeIf { releaseVersionMajor(it) == major } } ModalBottomSheet( @@ -188,7 +208,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 +217,7 @@ private fun VersionSelectorSheet( } }, update = { picker -> + if (picker.maxValue != maximumSupportedMajorVersion) picker.maxValue = maximumSupportedMajorVersion if (picker.value != activeMajor) picker.value = activeMajor }, modifier = Modifier @@ -218,7 +239,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) { @@ -271,6 +299,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 47668f8..81d5db4 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 @@ -22,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 @@ -53,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 @@ -66,6 +68,7 @@ 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.releaseVersionMajor import java.io.File /** @@ -118,6 +121,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 @@ -479,9 +484,20 @@ 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 -> { // Only recommend published builds; RCs are reachable through the picker. val selectedVersion = versionsResult.data.firstOrNull { !it.isReleaseCandidate() } @@ -525,6 +541,7 @@ class HomeViewModel( apks = apksResult, selectedReleaseVersion = selectedReleaseVersion, availableReleaseVersions = availableReleaseVersions, + releaseCandidates = releaseCandidates, ) } @@ -730,6 +747,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..bdb291a 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,12 @@ package org.mozilla.tryfox.util import java.util.regex.Pattern +private val RELEASE_MAJOR_REGEX = Regex("^(\\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() + data class Version( val fullStringVersion: String, val major: Int, diff --git a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt index 08156f0..0c5e419 100644 --- a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt @@ -14,6 +14,7 @@ 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 @@ -466,113 +467,48 @@ 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) - - assertEquals( - listOf("153.0.4", "153.0.4-RC2", "153.0.4-RC1", "153.0.3"), - (result as NetworkResult.Success).data, - ) - } - @Test - fun `Fenix release versions fall back to published versions when candidates 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) - - assertEquals(listOf("153.0.4"), (result as NetworkResult.Success).data) - } - - @Test - fun `Fenix candidate selection builds candidate APK URLs and isolated cache keys`() = runBlocking { - val api: MozillaArchivesApiService = mock() - val candidateHtml = loadHtmlResource("fenix-candidate-android.html") - val url = DefaultMozillaArchiveRepository.archiveUrlForCandidate("153.0.4", 2) - whenever(api.getHtmlPage(url)).thenReturn(candidateHtml) - - val result = DefaultMozillaArchiveRepository(api) - .getFenixReleaseBuildsForVersion("153.0.4-RC2", ReleaseType.Release) - - assertTrue(result is NetworkResult.Success) - val apks = (result as NetworkResult.Success).data - assertEquals(2, apks.size) - assertTrue(apks.all { it.version == "153.0.4-RC2" }) - assertTrue(apks.all { it.rawDateString == "candidate-153.0.4-build2" }) - assertTrue(apks.all { it.fullUrl.startsWith(url) }) - assertTrue(apks.any { it.fileName == "fenix-153.0.4.multi.android-arm64-v8a.apk" }) - } - - @Test - fun `nightly listing yields the latest day's builds including the universal directory`() { - val listingUrl = "https://archive.mozilla.org/pub/fenix/nightly/2026/10/" - val html = listOf( - "../", - "2026-10-05-21-22-56-fenix-159.0a1-android-arm64-v8a/", - "2026-10-05-21-22-56-fenix-159.0a1-android/", - "2026-10-06-19-44-15-fenix-159.0a1-android-arm64-v8a/", - "2026-10-06-19-44-15-fenix-159.0a1-android-x86_64/", - "2026-10-06-19-44-15-fenix-159.0a1-android/", - ).joinToString("\n") { "Dir$it" } - - val latest = parser.parseNightlyBuildsFromHtml(html, listingUrl, date = null) + val repository = DefaultMozillaArchiveRepository(api) - assertEquals(listOf("arm64-v8a", "x86_64", "universal"), latest.map { it.abiName }) - assertTrue(latest.all { it.rawDateString == "2026-10-06-19-44-15" && it.version == "159.0a1" && it.appName == "fenix" }) assertEquals( - "${listingUrl}2026-10-06-19-44-15-fenix-159.0a1-android/fenix-159.0a1.multi.android-universal.apk", - latest.single { it.abiName == "universal" }.fullUrl, + listOf("153.0.5", "153.0.4"), + (repository.getFenixCandidateBaseVersions(ReleaseType.Release) as NetworkResult.Success).data, ) assertEquals( - listOf("2026-10-05-21-22-56"), - parser.parseNightlyBuildsFromHtml(html, listingUrl, LocalDate(2026, 10, 5)).map { it.rawDateString }.distinct(), + 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 @@ -595,13 +531,16 @@ class FenixReleaseTest { @Test fun `failed archive listings are not cached`() = runBlocking { val api: MozillaArchivesApiService = mock() - whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL)) - .thenThrow(IllegalStateException("Releases unavailable")) - .thenReturn("153.0.4/") + whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.CANDIDATES_FENIX_BASE_URL)) + .thenThrow(IllegalStateException("Candidates unavailable")) + .thenReturn(loadHtmlResource("fenix-candidates-page.html")) val repository = DefaultMozillaArchiveRepository(api) - assertTrue(repository.getFenixReleaseVersions(ReleaseType.Release) is NetworkResult.Error) - assertEquals(listOf("153.0.4"), (repository.getFenixReleaseVersions(ReleaseType.Release) as NetworkResult.Success).data) + 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 { @@ -619,8 +558,7 @@ class FenixReleaseTest { assertEquals(listOf("153.0.4"), (release.await() as NetworkResult.Success).data) assertEquals(listOf("153.0b4"), (beta.await() as NetworkResult.Success).data) - // Loading release versions also fetches the candidates listing, so count releases fetches. - assertEquals(1, api.requestedUrls.count { it == releasesUrl }) + assertEquals(listOf(releasesUrl), api.requestedUrls) } @Test @@ -661,6 +599,73 @@ class FenixReleaseTest { 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() + whenever(api.getHtmlPage(DefaultMozillaArchiveRepository.CANDIDATES_FENIX_BASE_URL)) + .thenThrow(IllegalStateException("Candidates unavailable")) + + val result = DefaultMozillaArchiveRepository(api).getFenixCandidateBaseVersions(ReleaseType.Release) + + assertTrue(result is NetworkResult.Error) + } + + @Test + fun `Fenix candidate selection builds candidate APK URLs and isolated cache keys`() = runBlocking { + val api: MozillaArchivesApiService = mock() + val candidateHtml = loadHtmlResource("fenix-candidate-android.html") + val url = DefaultMozillaArchiveRepository.archiveUrlForCandidate("153.0.4", 2) + whenever(api.getHtmlPage(url)).thenReturn(candidateHtml) + + val result = DefaultMozillaArchiveRepository(api) + .getFenixReleaseBuildsForVersion("153.0.4-RC2", ReleaseType.Release) + + assertTrue(result is NetworkResult.Success) + val apks = (result as NetworkResult.Success).data + assertEquals(2, apks.size) + assertTrue(apks.all { it.version == "153.0.4-RC2" }) + assertTrue(apks.all { it.rawDateString == "candidate-153.0.4-build2" }) + assertTrue(apks.all { it.fullUrl.startsWith(url) }) + assertTrue(apks.any { it.fileName == "fenix-153.0.4.multi.android-arm64-v8a.apk" }) + } + + @Test + fun `nightly listing yields the latest day's builds including the universal directory`() { + val listingUrl = "https://archive.mozilla.org/pub/fenix/nightly/2026/10/" + val html = listOf( + "../", + "2026-10-05-21-22-56-fenix-159.0a1-android-arm64-v8a/", + "2026-10-05-21-22-56-fenix-159.0a1-android/", + "2026-10-06-19-44-15-fenix-159.0a1-android-arm64-v8a/", + "2026-10-06-19-44-15-fenix-159.0a1-android-x86_64/", + "2026-10-06-19-44-15-fenix-159.0a1-android/", + ).joinToString("\n") { "Dir$it" } + + val latest = parser.parseNightlyBuildsFromHtml(html, listingUrl, date = null) + + assertEquals(listOf("arm64-v8a", "x86_64", "universal"), latest.map { it.abiName }) + assertTrue(latest.all { it.rawDateString == "2026-10-06-19-44-15" && it.version == "159.0a1" && it.appName == "fenix" }) + assertEquals( + "${listingUrl}2026-10-06-19-44-15-fenix-159.0a1-android/fenix-159.0a1.multi.android-universal.apk", + latest.single { it.abiName == "universal" }.fullUrl, + ) + assertEquals( + listOf("2026-10-05-21-22-56"), + parser.parseNightlyBuildsFromHtml(html, listingUrl, LocalDate(2026, 10, 5)).map { it.rawDateString }.distinct(), + ) + } + // Helper method private fun abisIn(html: String, appName: String): List = 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 8aac988..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,83 @@ 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") @@ -1012,6 +1090,23 @@ class HomeViewModelTest { 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) From 120c4a4e272b52dbb43b30f76c01b6913774388a Mon Sep 17 00:00:00 2001 From: Ted Campbell Date: Thu, 8 Oct 2026 00:39:48 -0400 Subject: [PATCH 4/4] Hide release candidates in the version picker by default Add a "Show release candidates" checkbox to the Fenix version picker. RC builds are hidden, and not fetched, until it's checked, so most picks only see published builds. It starts checked when an RC is already selected, and is only shown for apps that have release candidates. --- .../ui/composables/VersionSelectorTest.kt | 86 +++++++++++++++++++ .../tryfox/ui/composables/VersionSelector.kt | 78 ++++++++++++++--- .../tryfox/ui/screens/HomeViewModel.kt | 6 +- .../java/org/mozilla/tryfox/util/Version.kt | 4 + app/src/main/res/values/strings.xml | 1 + 5 files changed, 159 insertions(+), 16 deletions(-) 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/main/java/org/mozilla/tryfox/ui/composables/VersionSelector.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/VersionSelector.kt index 6b44499..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 @@ -21,9 +21,12 @@ 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 @@ -39,6 +42,7 @@ 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 @@ -46,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 @@ -56,6 +61,7 @@ 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 @@ -69,6 +75,10 @@ internal fun VersionSelector( ) { 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, @@ -98,6 +108,8 @@ internal fun VersionSelector( availableVersions = availableReleaseVersions, releaseCandidates = releaseCandidates, onMajorBrowsed = onReleaseMajorBrowsed, + showReleaseCandidates = showReleaseCandidates, + onShowReleaseCandidatesChanged = { showReleaseCandidatesChoice = it }, onDismiss = { showSelector = false }, onConfirm = { version -> showSelector = false @@ -118,30 +130,50 @@ private fun VersionSelectorSheet( availableVersions: List, releaseCandidates: ReleaseCandidatesUiState, onMajorBrowsed: (Int) -> Unit, + showReleaseCandidates: Boolean, + onShowReleaseCandidatesChanged: (Boolean) -> Unit, onDismiss: () -> Unit, onConfirm: (String) -> Unit, ) { - val versionsByMajor = remember(availableVersions) { - availableVersions.mapNotNull { version -> releaseVersionMajor(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 }) } - // Majors that only have candidate builds so far must still be reachable on the wheel. - val knownMajors = remember(versionsByMajor, releaseCandidates.baseVersions) { - versionsByMajor.keys + releaseCandidates.baseVersions.mapNotNull(::releaseVersionMajor) + val publishedMajors = remember(availableVersions) { + availableVersions.filterNot(::isReleaseCandidateVersion).mapNotNull(::releaseVersionMajor).toSet() } + val candidateMajors = remember(availableVersions, releaseCandidates.baseVersions) { + (availableVersions.filter(::isReleaseCandidateVersion) + releaseCandidates.baseVersions).mapNotNull(::releaseVersionMajor).toSet() + } + // Majors that only have candidate builds so far are always reachable on the wheel. + val knownMajors = publishedMajors + candidateMajors val initialMajor = releaseVersionMajor(selectedVersion.orEmpty()) - ?: versionsByMajor.keys.maxOrNull() + ?: publishedMajors.maxOrNull() + ?: candidateMajors.maxOrNull() ?: MINIMUM_SUPPORTED_MAJOR_VERSION 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() - val isLoadingCandidates = activeMajor in releaseCandidates.loadingMajors + // 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 (isActiveMajorSelectable) onMajorBrowsed(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) @@ -172,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() @@ -227,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), @@ -265,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) 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 81d5db4..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 @@ -68,6 +68,7 @@ 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 @@ -500,7 +501,7 @@ class HomeViewModel( when (versionsResult) { is NetworkResult.Success -> { // Only recommend published builds; RCs are reachable through the picker. - val selectedVersion = versionsResult.data.firstOrNull { !it.isReleaseCandidate() } + val selectedVersion = versionsResult.data.firstOrNull { !isReleaseCandidateVersion(it) } val releaseResult = if (selectedVersion != null) { repository.getReleasesForVersion(selectedVersion) } else { @@ -569,8 +570,6 @@ class HomeViewModel( private suspend fun selectPublishedApk(apks: List): ApkUiModel? = candidatesByPreference(apks).firstOrNull { mozillaArchiveRepository.isPublished(it.url) } - private fun String.isReleaseCandidate(): Boolean = RELEASE_CANDIDATE_SUFFIX.containsMatchIn(this) - private fun convertParsedApksToUiModels(parsedApks: List): List { return parsedApks.map { parsedApk -> val buildDate = parsedApk.rawDateString?.rawNightlyBuildDate() @@ -1022,6 +1021,5 @@ class HomeViewModel( companion object { private const val TAG = "HomeViewModel" - private val RELEASE_CANDIDATE_SUFFIX = Regex("-RC\\d+$") } } 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 bdb291a..a9a3c7d 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Version.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Version.kt @@ -3,11 +3,15 @@ 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