From 6a8cf3f8f6dcd9224f25e3517f564b2f7965b363 Mon Sep 17 00:00:00 2001 From: Ted Campbell Date: Wed, 7 Oct 2026 23:34:36 -0400 Subject: [PATCH 1/2] Parse nightly and release archive listings the same way The nightly and release APK directories have compatible naming schemes so use a single parser for consistency. Add a general ArchiveApkDirectory type for both types of listing. The nightly lists still filter the universal folders since they might only contain AAB and not APK. Some of the existing tests needed fixes where simulated results didn't match the version of simulated requests. --- .../tryfox/data/ArchiveApkDirectory.kt | 42 +++++ .../tryfox/data/MozillaArchiveHtmlParser.kt | 149 +++++++----------- .../DefaultMozillaArchiveRepository.kt | 39 +---- .../org/mozilla/tryfox/FenixReleaseTest.kt | 71 ++++++--- 4 files changed, 159 insertions(+), 142 deletions(-) create mode 100644 app/src/main/java/org/mozilla/tryfox/data/ArchiveApkDirectory.kt diff --git a/app/src/main/java/org/mozilla/tryfox/data/ArchiveApkDirectory.kt b/app/src/main/java/org/mozilla/tryfox/data/ArchiveApkDirectory.kt new file mode 100644 index 0000000..8b2689c --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/data/ArchiveApkDirectory.kt @@ -0,0 +1,42 @@ +package org.mozilla.tryfox.data + +import org.mozilla.tryfox.model.MozillaArchiveApk + +/** + * One APK build directory on archive.mozilla.org. Nightly and release listings share the naming + * scheme `[-]--android[-]/`, where only nightlies have a timestamp + * and a directory without an ABI suffix holds the universal build. + * + * The APK file name is derived from the directory name and may not actually be published (e.g. + * some universal directories only hold an `.aab`). + */ +data class ArchiveApkDirectory( + val directory: String, + val timestamp: String?, + val appName: String, + val version: String, + val abi: String, +) { + val fileName: String get() = "$appName-$version.multi.android-$abi.apk" + + /** + * @param listingUrl The URL of the listing this directory was found in. + * @param appName The app the APK is shown under, e.g. `fenix-release` for a `fenix` directory. + * @param displayVersion The version shown to the user, e.g. `157.0-RC2` for a candidate build. + * @param buildKey Isolates the download cache per build; nightlies use their timestamp. + */ + fun toApk( + listingUrl: String, + appName: String = this.appName, + displayVersion: String = version, + buildKey: String? = timestamp, + ) = MozillaArchiveApk( + originalString = directory, + rawDateString = buildKey, + appName = appName, + version = displayVersion, + abiName = abi, + fullUrl = "$listingUrl$directory$fileName", + fileName = fileName, + ) +} diff --git a/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt b/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt index 8c9fc1d..4e70c15 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt @@ -2,45 +2,69 @@ package org.mozilla.tryfox.data import kotlinx.datetime.LocalDate import org.mozilla.tryfox.model.MozillaArchiveApk -import java.util.regex.Pattern +import org.mozilla.tryfox.util.UNIVERSAL_ABI +import org.mozilla.tryfox.util.isUniversalAbi class MozillaArchiveHtmlParser { + private companion object { + // [-]--android[-] + val APK_DIRECTORY_PATTERN = + Regex("^(?:(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})-)?(.+?)-(\\d+\\.\\d+(?:\\.\\d+)?(?:[ab]\\d+)?)-android(?:-(.+))?$") + } + fun parseNightlyBuildsFromHtml( html: String, archiveUrl: String, date: LocalDate?, ): List { - val htmlPattern = Regex("Dir\\s*([^<]+/)") - val rawBuildStrings = htmlPattern.findAll(html) - .mapNotNull { it.groups[1]?.value } - .filter { it != "../" } - .toList() - - val buildsForDate = if (date != null) { - val dateString = date.toString() - rawBuildStrings.filter { it.startsWith(dateString) } - } else { - val buildsByDay = rawBuildStrings.groupBy { it.substring(0, 10) } - if (buildsByDay.isEmpty()) return emptyList() - val latestDay = buildsByDay.keys.maxOrNull() ?: return emptyList() - buildsByDay[latestDay] ?: emptyList() - } + // Nightly universal directories have AAB without APK so be sure to skip them. + val builds = parseApkDirectoriesFromHtml(html).filter { it.timestamp != null && !isUniversalAbi(it.abi) } + val day = date?.toString() ?: builds.maxOfOrNull { it.timestamp!!.take(10) } ?: return emptyList() + return builds.filter { it.timestamp!!.startsWith(day) }.map { it.toApk(archiveUrl) } + } - return buildsForDate.mapNotNull { buildString -> - parseBuildString(buildString, archiveUrl) - } + /** Parses the APK build directories in an archive listing; see [ArchiveApkDirectory]. */ + fun parseApkDirectoriesFromHtml(html: String): List = + parseDirectoryNamesFromHtml(html).mapNotNull(::parseApkDirectory) + + private fun parseApkDirectory(name: String): ArchiveApkDirectory? { + val match = APK_DIRECTORY_PATTERN.matchEntire(name) ?: return null + val (timestamp, appName, version, abi) = match.destructured + return ArchiveApkDirectory( + directory = "$name/", + timestamp = timestamp.ifEmpty { null }, + appName = appName, + version = version, + abi = abi.ifEmpty { UNIVERSAL_ABI }, + ) } fun parseFenixReleasesFromHtml(html: String, releaseType: ReleaseType = ReleaseType.Beta): String { return parseFenixReleaseVersionsFromHtml(html, releaseType).firstOrNull() ?: "" } - fun parseFenixReleaseVersionsFromHtml(html: String, releaseType: ReleaseType = ReleaseType.Beta): List { - val releasePattern = Regex("([0-9.]+[a-zA-Z0-9.-]*)/") - val rawReleaseStrings = releasePattern.findAll(html) - .mapNotNull { it.groups[1]?.value } - .toList() + fun parseFenixReleaseVersionsFromHtml(html: String, releaseType: ReleaseType = ReleaseType.Beta): List = + releaseVersionsFromDirectories(parseDirectoryNamesFromHtml(html), releaseType) + + /** Returns candidate base versions, without their `-candidates` directory suffix. */ + fun parseFenixCandidateVersionsFromHtml(html: String, releaseType: ReleaseType): List = + candidateBaseVersionsFromDirectories(parseDirectoryNamesFromHtml(html), releaseType) + + /** + * Extracts the directory names (without trailing `/`) from an archive index page. This is the + * channel-independent form of a listing, so one fetch can serve both Release and Beta, and + * it's what [parseApkDirectoriesFromHtml] reads build directories from. + */ + fun parseDirectoryNamesFromHtml(html: String): List { + val directoryPattern = Regex("([^<]+)/") + return directoryPattern.firstGroupOfAll(html) + } + + /** Filters a `releases/` listing to the versions of [releaseType], newest first. */ + fun releaseVersionsFromDirectories(directories: List, releaseType: ReleaseType): List { + val releasePattern = Regex("[0-9.]+[a-zA-Z0-9.-]*") + val rawReleaseStrings = directories.filter { it.matches(releasePattern) } return when (releaseType) { ReleaseType.Beta -> { @@ -56,11 +80,9 @@ class MozillaArchiveHtmlParser { } } - /** Returns candidate base versions, without their `-candidates` directory suffix. */ - fun parseFenixCandidateVersionsFromHtml(html: String, releaseType: ReleaseType): List { - val directoryPattern = Regex("([^<]+/)") - return directoryPattern.findAll(html) - .mapNotNull { it.groups[1]?.value?.removeSuffix("/") } + /** Filters a `candidates/` listing to the base versions of [releaseType], newest first. */ + fun candidateBaseVersionsFromDirectories(directories: List, releaseType: ReleaseType): List { + return directories .filter { it.endsWith("-candidates") } .map { it.removeSuffix("-candidates") } .filter { candidate -> @@ -71,48 +93,16 @@ class MozillaArchiveHtmlParser { } .distinct() .sortedWith(::compareReleaseVersions) - .toList() .reversed() } /** Extracts numeric build directories such as `build1/` and `build12/`. */ fun parseCandidateBuildNumbersFromHtml(html: String): List { val directoryPattern = Regex("build(\\d+)/") - return directoryPattern.findAll(html) - .mapNotNull { it.groups[1]?.value?.toIntOrNull() } + return directoryPattern.firstGroupOfAll(html) + .mapNotNull { it.toIntOrNull() } .distinct() .sortedDescending() - .toList() - } - - fun parseFenixReleaseAbisFromHtml(html: String, appName: String): List { - // Pattern: {appName}-D+.D+(.D+)?-android-ABI/ or {appName}-D+.D+(.D+)?-android/ - // Also supports beta/alpha markers: {appName}-D+.D+(.D+)?[ab]D+-android-ABI/ - val htmlPattern = Regex("Dir\\s*([^<]+/)") - val rawBuildStrings = htmlPattern.findAll(html) - .mapNotNull { it.groups[1]?.value } - .filter { it != "../" } - .toList() - - val abis = mutableListOf() - - for (buildString in rawBuildStrings) { - // Pattern: {appName}-D+.D+(.D+)?[ab]D+-android-ABI/ or {appName}-D+.D+(.D+)?-android/ - // Also supports: {appName}-D+.D+(.D+)?-android-ABI/ (stable releases) - // Examples: - // - fenix-145.0-android-arm64-v8a/ (stable) - // - fenix-145.0-android/ (stable universal) - // - fenix-146.0b5-android-arm64-v8a/ (beta) - // - fenix-146.0b5-android/ (beta universal) - val pattern = Regex("^$appName-\\d+\\.\\d+(?:\\.\\d+)?(?:[ab]\\d+)?-android(?:-(.+?))?/$") - val matchResult = pattern.find(buildString) - - if (matchResult != null) { - val abi = matchResult.groups[1]?.value - abis.add(abi ?: "universal") - } - } - return abis } internal fun compareReleaseVersions(version1: String, version2: String): Int { @@ -153,30 +143,13 @@ class MozillaArchiveHtmlParser { return !isPreRelease && version.matches(Regex("\\d+\\.\\d+(\\.\\d+)?")) } - private fun parseBuildString(buildString: String, archiveUrl: String): MozillaArchiveApk? { - val apkPattern = - Pattern.compile("^(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})-(.*?)-([^-]+)-android-(.*?)/$") - val matcher = apkPattern.matcher(buildString) - if (matcher.matches()) { - val rawDate = matcher.group(1) ?: "" - val appNameResult = matcher.group(2) ?: "" - val version = matcher.group(3) ?: "" - val abi = matcher.group(4) ?: "" - - val fileName = "$appNameResult-$version.multi.android-$abi.apk" - val fullUrl = "${archiveUrl}${buildString}$fileName" - - return MozillaArchiveApk( - originalString = buildString, - rawDateString = rawDate, - appName = appNameResult, - version = version, - abiName = abi, - fullUrl = fullUrl, - fileName = fileName, - ) - } - - return null + /** + * The first capture group of every match. Kotlin's [Regex.findAll] creates a new Matcher per + * match, and on Android each one copies the whole input, which makes scanning a large listing + * cost matches x length (about 200 ms for `fenix/releases/`). One Matcher avoids that. + */ + private fun Regex.firstGroupOfAll(input: CharSequence): List { + val matcher = toPattern().matcher(input) + return buildList { while (matcher.find()) add(matcher.group(1)) } } } 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 18533ca..dd3ad46 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 @@ -245,50 +245,21 @@ class DefaultMozillaArchiveRepository( archiveUrl: String, archiveAppName: String, resultAppName: String, + // Empty for releases; candidates need an isolated cache key. cacheBuildKey: String, ): NetworkResult> { val releaseHtml = mozillaArchivesApiService.getHtmlPage(archiveUrl) - val abis = mozillaArchiveHtmlParser.parseFenixReleaseAbisFromHtml(releaseHtml, archiveAppName) - - if (abis.isEmpty()) { - return NetworkResult.Error("No ABIs found for release $version", null) - } - - val apks = abis.map { abi -> - constructReleaseApk(version, displayVersion, abi, archiveUrl, archiveAppName, resultAppName, cacheBuildKey) - } + val apks = mozillaArchiveHtmlParser.parseApkDirectoriesFromHtml(releaseHtml) + .filter { it.appName == archiveAppName } + .map { it.toApk(archiveUrl, appName = resultAppName, displayVersion = displayVersion, buildKey = cacheBuildKey) } if (apks.isEmpty()) { - return NetworkResult.Error("Failed to construct APKs for release $version", null) + return NetworkResult.Error("No ABIs found for release $version", null) } return NetworkResult.Success(apks) } - private fun constructReleaseApk( - version: String, - displayVersion: String, - abi: String, - releaseBaseUrl: String, - archiveAppName: String, - resultAppName: String, - cacheBuildKey: String, - ): MozillaArchiveApk { - val buildString = "$archiveAppName-$version-android${if (abi == "universal") "" else "-$abi"}/" - val fileName = "$archiveAppName-$version.multi.android-$abi.apk" - val fullUrl = "${releaseBaseUrl}${buildString}$fileName" - - return MozillaArchiveApk( - originalString = buildString, - rawDateString = cacheBuildKey, // Empty for releases; candidates need an isolated cache key. - appName = resultAppName, - version = displayVersion, - abiName = abi, - fullUrl = fullUrl, - fileName = fileName, - ) - } - private suspend fun fetchFenixCandidateVersions( releaseType: ReleaseType, ): List { diff --git a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt index d4a550e..470ad59 100644 --- a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt @@ -1,6 +1,7 @@ package org.mozilla.tryfox import kotlinx.coroutines.runBlocking +import kotlinx.datetime.LocalDate import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test @@ -22,7 +23,7 @@ class FenixReleaseTest { fun `test getFenixReleaseBuilds returns list of APKs for beta release`() = runBlocking { val mockApiService: MozillaArchivesApiService = mock() val releasesListHtml = loadHtmlResource("fenix-releases-page.html") - val releaseDetailsHtml = loadHtmlResource("fenix-releases-145.html") + val releaseDetailsHtml = loadHtmlResource("fenix-releases-146b5.html") whenever(mockApiService.getHtmlPage(DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL)) .thenReturn(releasesListHtml) @@ -67,7 +68,7 @@ class FenixReleaseTest { fun `test constructed release APKs have correct URLs`() = runBlocking { val mockApiService: MozillaArchivesApiService = mock() val releasesListHtml = loadHtmlResource("fenix-releases-page.html") - val releaseDetailsHtml = loadHtmlResource("fenix-releases-145.html") + val releaseDetailsHtml = loadHtmlResource("fenix-releases-146b5.html") whenever(mockApiService.getHtmlPage(DefaultMozillaArchiveRepository.RELEASES_FENIX_BASE_URL)) .thenReturn(releasesListHtml) @@ -109,16 +110,16 @@ class FenixReleaseTest { val mockApiService: MozillaArchivesApiService = mock() val releaseDetailsHtml = loadHtmlResource("fenix-releases-145.html") - whenever(mockApiService.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForRelease("146.0.1"))) + whenever(mockApiService.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForRelease("145.0"))) .thenReturn(releaseDetailsHtml) val repository = DefaultMozillaArchiveRepository(mockApiService) - val result = repository.getFenixReleaseBuildsForVersion("146.0.1", ReleaseType.Release) + val result = repository.getFenixReleaseBuildsForVersion("145.0", ReleaseType.Release) assertTrue(result is NetworkResult.Success) if (result is NetworkResult.Success) { - assertTrue(result.data.all { it.version == "146.0.1" }) - assertTrue(result.data.any { it.fullUrl.contains("fenix-146.0.1.multi.android-arm64-v8a.apk") }) + assertTrue(result.data.all { it.version == "145.0" }) + assertTrue(result.data.any { it.fullUrl.contains("fenix-145.0.multi.android-arm64-v8a.apk") }) } } @@ -145,17 +146,17 @@ class FenixReleaseTest { val mockApiService: MozillaArchivesApiService = mock() val releaseDetailsHtml = loadHtmlResource("focus-releases-147.html") - whenever(mockApiService.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForRelease(DefaultMozillaArchiveRepository.RELEASES_FOCUS_BASE_URL, "146.0.1"))) + whenever(mockApiService.getHtmlPage(DefaultMozillaArchiveRepository.archiveUrlForRelease(DefaultMozillaArchiveRepository.RELEASES_FOCUS_BASE_URL, "147.0.1"))) .thenReturn(releaseDetailsHtml) val repository = DefaultMozillaArchiveRepository(mockApiService) - val result = repository.getFocusReleaseBuildsForVersion("146.0.1") + val result = repository.getFocusReleaseBuildsForVersion("147.0.1") assertTrue(result is NetworkResult.Success) if (result is NetworkResult.Success) { - assertTrue(result.data.all { it.version == "146.0.1" }) + assertTrue(result.data.all { it.version == "147.0.1" }) assertTrue(result.data.all { it.appName == "focus-release" }) - assertTrue(result.data.any { it.fullUrl.contains("focus-146.0.1.multi.android-arm64-v8a.apk") }) + assertTrue(result.data.any { it.fullUrl.contains("focus-147.0.1.multi.android-arm64-v8a.apk") }) } } @@ -192,7 +193,7 @@ class FenixReleaseTest { fun `test parseFenixReleaseAbisFromHtml extracts all ABIs from release HTML`() { val htmlContent = loadHtmlResource("fenix-releases-145.html") - val result = parser.parseFenixReleaseAbisFromHtml(htmlContent, "fenix") + val result = abisIn(htmlContent, "fenix") println("Extracted ABIs: $result") assertEquals(4, result.size, "Should extract 4 ABIs") @@ -206,7 +207,7 @@ class FenixReleaseTest { fun `test parseFenixReleaseAbisFromHtml extracts in correct order`() { val htmlContent = loadHtmlResource("fenix-releases-145.html") - val result = parser.parseFenixReleaseAbisFromHtml(htmlContent, "fenix") + val result = abisIn(htmlContent, "fenix") // The order should match the order in the HTML val expected = listOf("arm64-v8a", "armeabi-v7a", "x86_64", "universal") @@ -219,11 +220,11 @@ class FenixReleaseTest { val htmlContent = loadHtmlResource("fenix-releases-145.html") // Using "fenix" app name (correct one) - val result = parser.parseFenixReleaseAbisFromHtml(htmlContent, "fenix") + val result = abisIn(htmlContent, "fenix") assertEquals(4, result.size, "Should extract 4 ABIs with correct app name") // Using "focus" app name (shouldn't match fenix entries) - val resultFocus = parser.parseFenixReleaseAbisFromHtml(htmlContent, "focus") + val resultFocus = abisIn(htmlContent, "focus") assertEquals(0, resultFocus.size, "Should extract 0 ABIs with wrong app name") } @@ -231,7 +232,7 @@ class FenixReleaseTest { fun `test parseFenixReleaseAbisFromHtml empty HTML returns empty list`() { val htmlContent = "" - val result = parser.parseFenixReleaseAbisFromHtml(htmlContent, "fenix") + val result = abisIn(htmlContent, "fenix") assertEquals(0, result.size, "Should return empty list for HTML with no matching entries") } @@ -240,7 +241,7 @@ class FenixReleaseTest { fun `test parseFenixReleaseAbisFromHtml identifies universal ABI correctly`() { val htmlContent = loadHtmlResource("fenix-releases-145.html") - val result = parser.parseFenixReleaseAbisFromHtml(htmlContent, "fenix") + val result = abisIn(htmlContent, "fenix") // The last entry should be "universal" (fenix-145.0-android/) assertTrue(result.last() == "universal", "Last ABI should be universal") @@ -250,7 +251,7 @@ class FenixReleaseTest { fun `test parseFenixReleaseAbisFromHtml extracts all ABIs from beta release HTML`() { val htmlContent = loadHtmlResource("fenix-releases-146b5.html") - val result = parser.parseFenixReleaseAbisFromHtml(htmlContent, "fenix") + val result = abisIn(htmlContent, "fenix") println("Extracted ABIs from beta release: $result") assertEquals(4, result.size, "Should extract 4 ABIs from beta release") @@ -264,7 +265,7 @@ class FenixReleaseTest { fun `test parseFenixReleaseAbisFromHtml handles beta markers correctly`() { val htmlContent = loadHtmlResource("fenix-releases-146b5.html") - val result = parser.parseFenixReleaseAbisFromHtml(htmlContent, "fenix") + val result = abisIn(htmlContent, "fenix") // Verify the order matches the HTML val expected = listOf("arm64-v8a", "armeabi-v7a", "x86_64", "universal") @@ -275,7 +276,7 @@ class FenixReleaseTest { fun `test parseFenixReleaseAbisFromHtml identifies universal ABI in beta release`() { val htmlContent = loadHtmlResource("fenix-releases-146b5.html") - val result = parser.parseFenixReleaseAbisFromHtml(htmlContent, "fenix") + val result = abisIn(htmlContent, "fenix") // The last entry should be "universal" (fenix-146.0b5-android/) assertTrue(result.last() == "universal", "Last ABI in beta release should be universal") @@ -285,7 +286,7 @@ class FenixReleaseTest { fun `test parseFenixReleaseAbisFromHtml extracts specific ABI from beta pattern`() { val htmlContent = loadHtmlResource("fenix-releases-146b5.html") - val result = parser.parseFenixReleaseAbisFromHtml(htmlContent, "fenix") + val result = abisIn(htmlContent, "fenix") // Verify specific ABIs are correctly extracted from beta version format assertTrue(result.contains("arm64-v8a"), "fenix-146.0b5-android-arm64-v8a/ should extract arm64-v8a") @@ -538,8 +539,38 @@ class FenixReleaseTest { assertTrue(apks.any { it.fileName == "fenix-153.0.4.multi.android-arm64-v8a.apk" }) } + @Test + fun `nightly listing yields the latest day's builds`() { + 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) + + // Universal nightly directories are skipped until missing APKs can be detected. + assertEquals(listOf("arm64-v8a", "x86_64"), 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-arm64-v8a/fenix-159.0a1.multi.android-arm64-v8a.apk", + latest.single { it.abiName == "arm64-v8a" }.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 = + parser.parseApkDirectoriesFromHtml(html).filter { it.appName == appName }.map { it.abi } + private fun loadHtmlResource(resourceName: String): String { return this::class.java.classLoader?.getResource(resourceName)?.readText() ?: throw IllegalArgumentException("Resource not found: $resourceName") From c7562a9e4cb70e6e6df52d7e56fa9ce0eff4366d Mon Sep 17 00:00:00 2001 From: Ted Campbell Date: Tue, 6 Oct 2026 22:30:34 -0400 Subject: [PATCH 2/2] Default to the device's ABI and ignore archive directories without APKs A clean install without a preference set will default to using preferred ABI for smaller downloads. The settings can still be used to select universal APKs if they are preferred. An ordered list of preferred ABIs is derived from the user setting and what is supported on device and used to pick from available APKs. Since APK URLs are constructed from directory names, they may not actually exist on the server (such as if only an AAB is in the universal folder). This patch adds a network check when populating the AppCard so it does not try (and fail) to install a missing APK and an alternative exists. If universal builds are preferred the user can set that setting and we will try to use them if they exist for the target version and fallback automatically to a non-universal APK otherwise. --- .../tryfox/data/FakeUserDataRepository.kt | 5 +- .../tryfox/ui/screens/HomeAppCardTest.kt | 2 + .../tryfox/data/ArchiveApkDirectory.kt | 2 +- .../tryfox/data/MozillaArchiveHtmlParser.kt | 4 +- .../DefaultMozillaArchiveRepository.kt | 11 ++ .../repositories/DefaultUserDataRepository.kt | 5 +- .../repositories/MozillaArchiveRepository.kt | 6 + .../data/repositories/UserDataRepository.kt | 7 +- .../java/org/mozilla/tryfox/di/AppModule.kt | 1 + .../network/MozillaArchivesApiService.kt | 5 + .../mozilla/tryfox/ui/models/AbiUiModel.kt | 11 -- .../mozilla/tryfox/ui/models/AppUiModel.kt | 9 +- .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 5 +- .../mozilla/tryfox/ui/screens/HomeScreen.kt | 1 - .../tryfox/ui/screens/HomeScreenState.kt | 2 - .../tryfox/ui/screens/HomeViewModel.kt | 103 +++++++---- .../tryfox/ui/screens/SettingsViewModel.kt | 12 +- .../main/java/org/mozilla/tryfox/util/Abi.kt | 55 +++--- .../org/mozilla/tryfox/FenixReleaseTest.kt | 9 +- .../data/FakeMozillaArchiveRepository.kt | 7 + .../data/managers/FakeUserDataRepository.kt | 5 +- .../tryfox/ui/screens/HomeViewModelTest.kt | 169 ++++++++++++++++-- .../ui/screens/SettingsViewModelTest.kt | 14 +- .../mozilla/tryfox/util/AbiPreferenceTest.kt | 88 ++++++--- 24 files changed, 398 insertions(+), 140 deletions(-) diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt index 74ee0d8..3e1a54a 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt @@ -8,7 +8,6 @@ import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.lan.LanReceiveIdentity import org.mozilla.tryfox.model.HomeScreenLayout -import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI /** * A fake implementation of [org.mozilla.tryfox.data.repositories.UserDataRepository] for testing purposes. @@ -23,8 +22,8 @@ class FakeUserDataRepository : UserDataRepository { override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow private val _homeScreenLayoutFlow = MutableStateFlow(HomeScreenLayout.OneCardPerApp) override val homeScreenLayoutFlow: Flow = _homeScreenLayoutFlow - private val _preferredAbiFlow = MutableStateFlow(DEFAULT_PREFERRED_ABI) - override val preferredAbiFlow: Flow = _preferredAbiFlow + private val _preferredAbiFlow = MutableStateFlow(null) + override val preferredAbiFlow: Flow = _preferredAbiFlow override suspend fun saveLastSearchedEmail(email: String) { recordSearch("try", email) 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 8f72ed2..1e29051 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 @@ -184,6 +184,7 @@ class HomeAppCardTest { apkDir = File("/tmp/fenix"), ), ), + selectedApkKey = "fenix/2024-12-31-09-17-32/fenix.apk", ), ), ), @@ -240,6 +241,7 @@ class HomeAppCardTest { apkDir = File("/tmp/focus"), ), ), + selectedApkKey = "focus-release/focus.apk", ), ), ), diff --git a/app/src/main/java/org/mozilla/tryfox/data/ArchiveApkDirectory.kt b/app/src/main/java/org/mozilla/tryfox/data/ArchiveApkDirectory.kt index 8b2689c..de1d4a8 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/ArchiveApkDirectory.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/ArchiveApkDirectory.kt @@ -8,7 +8,7 @@ import org.mozilla.tryfox.model.MozillaArchiveApk * and a directory without an ABI suffix holds the universal build. * * The APK file name is derived from the directory name and may not actually be published (e.g. - * some universal directories only hold an `.aab`). + * some universal directories only hold an `.aab`), so check it before offering it. */ data class ArchiveApkDirectory( val directory: String, diff --git a/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt b/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt index 4e70c15..b540dc2 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt @@ -3,7 +3,6 @@ package org.mozilla.tryfox.data import kotlinx.datetime.LocalDate import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.util.UNIVERSAL_ABI -import org.mozilla.tryfox.util.isUniversalAbi class MozillaArchiveHtmlParser { @@ -18,8 +17,7 @@ class MozillaArchiveHtmlParser { archiveUrl: String, date: LocalDate?, ): List { - // Nightly universal directories have AAB without APK so be sure to skip them. - val builds = parseApkDirectoriesFromHtml(html).filter { it.timestamp != null && !isUniversalAbi(it.abi) } + val builds = parseApkDirectoriesFromHtml(html).filter { it.timestamp != null } val day = date?.toString() ?: builds.maxOfOrNull { it.timestamp!!.take(10) } ?: return emptyList() return builds.filter { it.timestamp!!.startsWith(day) }.map { it.toApk(archiveUrl) } } 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 dd3ad46..1d72e3c 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 @@ -22,6 +22,7 @@ import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_RELEASE import retrofit2.HttpException +import java.net.HttpURLConnection class DefaultMozillaArchiveRepository( private val mozillaArchivesApiService: MozillaArchivesApiService, @@ -283,6 +284,16 @@ class DefaultMozillaArchiveRepository( } } + 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) || + mozillaArchivesApiService.head(url).code() != HttpURLConnection.HTTP_NOT_FOUND + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + true + } + private data class CandidateVersion(val baseVersion: String, val buildNumber: Int) private suspend fun getHtmlPageOrNull(url: String): String? = try { diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt index 7d51d3d..3c3f058 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt @@ -16,7 +16,6 @@ import org.mozilla.tryfox.data.SearchHistoryEntry import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.lan.LanReceiveIdentity import org.mozilla.tryfox.model.HomeScreenLayout -import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI /** * A repository that stores the last searched email in a DataStore. @@ -68,8 +67,8 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos homeScreenLayoutFromStoredValue(preferences[PreferenceKeys.HOME_SCREEN_LAYOUT]) } - override val preferredAbiFlow: Flow = appContext.dataStore.data.map { preferences -> - preferences[PreferenceKeys.PREFERRED_ABI]?.takeIf { it.isNotBlank() } ?: DEFAULT_PREFERRED_ABI + override val preferredAbiFlow: Flow = appContext.dataStore.data.map { preferences -> + preferences[PreferenceKeys.PREFERRED_ABI]?.takeIf { it.isNotBlank() } } override suspend fun saveLastSearchedEmail(email: String) { 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 157e5d4..ab26813 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 @@ -41,4 +41,10 @@ interface MozillaArchiveRepository { suspend fun getFocusReleaseBuildsForVersion(version: String): NetworkResult> suspend fun getFocusBetaBuildsForVersion(version: String): NetworkResult> + + /** + * Whether a file is actually published at [url]. Only a definite "not found" from the archive + * counts as missing; other hosts and network errors return true so the download reports them. + */ + suspend fun isPublished(url: String): Boolean } diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt index 5b9c2ba..e1622df 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt @@ -18,8 +18,11 @@ interface UserDataRepository { val lanReceiveIdentityFlow: Flow val homeScreenLayoutFlow: Flow - /** The ABI variant to favour when a build offers several. Emits `universal` by default. */ - val preferredAbiFlow: Flow + /** + * The ABI variant to favour when a build offers several, as picked in Settings. Emits null + * until the user picks one, so the device's default applies. + */ + val preferredAbiFlow: Flow /** * Saves the last searched email. diff --git a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt index 187df5f..b175145 100644 --- a/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt +++ b/app/src/main/java/org/mozilla/tryfox/di/AppModule.kt @@ -239,6 +239,7 @@ val viewModelModule = module { get(), get(), get(), + get(), ) } viewModel { params -> diff --git a/app/src/main/java/org/mozilla/tryfox/network/MozillaArchivesApiService.kt b/app/src/main/java/org/mozilla/tryfox/network/MozillaArchivesApiService.kt index f97ccc5..ea1faea 100644 --- a/app/src/main/java/org/mozilla/tryfox/network/MozillaArchivesApiService.kt +++ b/app/src/main/java/org/mozilla/tryfox/network/MozillaArchivesApiService.kt @@ -1,6 +1,8 @@ package org.mozilla.tryfox.network +import retrofit2.Response import retrofit2.http.GET +import retrofit2.http.HEAD import retrofit2.http.Url /** @@ -9,4 +11,7 @@ import retrofit2.http.Url interface MozillaArchivesApiService { @GET suspend fun getHtmlPage(@Url url: String): String + + @HEAD + suspend fun head(@Url url: String): Response } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/AbiUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/AbiUiModel.kt index 2e23007..0a6f44b 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/AbiUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/AbiUiModel.kt @@ -8,14 +8,3 @@ data class AbiUiModel( ) { val isUniversal: Boolean get() = isUniversalAbi(name) } - -/** - * Picks the variant matching [preferredAbi], falling back to universal, then to anything the - * device can install, then to whatever came first. Nightly builds ship no universal variant, so - * an unmatched affinity still resolves to an installable APK. - */ -fun List.preferredAbiApk(preferredAbi: String): ApkUiModel? = - firstOrNull { preferredAbi.equals(it.abi.name, ignoreCase = true) } - ?: firstOrNull { it.abi.isUniversal } - ?: firstOrNull { it.abi.isSupported } - ?: firstOrNull() 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 339425d..60a7296 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 @@ -6,7 +6,14 @@ import org.mozilla.tryfox.util.Version sealed class ApksResult { data object Loading : ApksResult() - data class Success(val apks: List) : ApksResult() + + /** + * A build's APKs, one per ABI in archive order, and the one the card offers, chosen by the view + * model. [selectedApkKey] is null when none suits this device. + */ + data class Success(val apks: List, val selectedApkKey: String?) : ApksResult() { + val selectedApk: ApkUiModel? get() = apks.firstOrNull { it.uniqueKey == selectedApkKey } + } data class Error(val message: String) : ApksResult() } 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 fa4ee4e..c7f2f39 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,8 +67,6 @@ 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.preferredAbiApk -import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG @@ -96,7 +94,6 @@ internal fun HomeAppCard( onBuildSelected: (String, String) -> Unit, onDismissBuildPicker: (String) -> Unit, modifier: Modifier = Modifier, - preferredAbi: String = DEFAULT_PREFERRED_ABI, ) { val app = card.selectedApp val appState = app.toAppState() @@ -107,7 +104,7 @@ internal fun HomeAppCard( val isNightly = app.name == FENIX || app.name == FOCUS val isDebug = app.name == FENIX_DEBUG || app.name == FOCUS_DEBUG val isVersionSelectable = app.name in setOf(FENIX_RELEASE, FENIX_BETA, FOCUS_RELEASE, FOCUS_BETA) - val selectedApk = (app.apks as? ApksResult.Success)?.apks?.preferredAbiApk(preferredAbi) + val selectedApk = (app.apks as? ApksResult.Success)?.selectedApk Card( modifier = modifier 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 83e9fb8..09b2ab9 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 @@ -221,7 +221,6 @@ fun HomeScreen( onDismissBuildPicker = { appName -> homeViewModel.onDismissBuildPicker(appName) }, - preferredAbi = currentScreenState.preferredAbi, ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt index 1beda2f..58f5668 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt @@ -3,7 +3,6 @@ package org.mozilla.tryfox.ui.screens import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.ui.models.AppUiModel -import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI /** * Represents the various states of the Home screen. @@ -24,6 +23,5 @@ sealed class HomeScreenState { val isDownloadingAnyFile: Boolean, val selectedAppNames: Map = HomeAppFamily.entries.associateWith { it.defaultAppName }, val homeScreenLayout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, - val preferredAbi: String = DEFAULT_PREFERRED_ABI, ) : HomeScreenState() } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt index 11a440f..091978a 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 @@ -31,6 +31,7 @@ import org.mozilla.tryfox.data.repositories.EmptyInstalledTryBuildRepository import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository import org.mozilla.tryfox.data.repositories.HomeDataSnapshot import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository +import org.mozilla.tryfox.data.repositories.MozillaArchiveRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.data.repositories.VersionAwareReleaseRepository @@ -50,7 +51,6 @@ 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.newVersionAvailable -import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG @@ -61,8 +61,8 @@ import org.mozilla.tryfox.util.FOCUS_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TRYFOX +import org.mozilla.tryfox.util.abiPreferenceOrder import org.mozilla.tryfox.util.isAbiSupported -import org.mozilla.tryfox.util.resolvePreferredAbi import java.io.File /** @@ -74,6 +74,7 @@ import java.io.File * @param cacheManager Manager for handling application cache. * @param intentManager Manager for handling intents, such as APK installation. * @param ioDispatcher The coroutine dispatcher for background operations. + * @param mozillaArchiveRepository Used to check that the APK a card offers is actually published. */ class HomeViewModel( private val releaseRepositories: List, @@ -83,6 +84,7 @@ class HomeViewModel( private val intentManager: IntentManager, private val installCoordinator: ApkInstallCoordinator, private val ioDispatcher: CoroutineDispatcher, + private val mozillaArchiveRepository: MozillaArchiveRepository, private val userDataRepository: UserDataRepository? = null, private val homeDataCacheRepository: HomeDataCacheRepository = EmptyHomeDataCacheRepository, private val installedTryBuildRepository: InstalledTryBuildRepository = EmptyInstalledTryBuildRepository, @@ -104,7 +106,7 @@ class HomeViewModel( private var homeScreenLayout = HomeScreenLayout.OneCardPerApp @Volatile - private var preferredAbi = DEFAULT_PREFERRED_ABI + private var abiOrder = abiPreferenceOrder(null, supportedAbis) @Volatile private var selectedHomeAppNames = HomeAppFamily.entries.associateWith { it.defaultAppName } @@ -147,15 +149,11 @@ class HomeViewModel( userDataRepository?.preferredAbiFlow ?.onEach { storedAbi -> - val resolvedAbi = resolvePreferredAbi(storedAbi, supportedAbis) - preferredAbi = resolvedAbi - _homeScreenState.update { currentState -> - if (currentState is HomeScreenState.Loaded) { - currentState.copy(preferredAbi = resolvedAbi) - } else { - currentState - } - } + val order = abiPreferenceOrder(storedAbi, supportedAbis) + // The flow also emits the stored value on start; only reselect when the order changes. + if (order == abiOrder) return@onEach + abiOrder = order + reselectApks() } ?.launchIn(viewModelScope) @@ -212,6 +210,22 @@ class HomeViewModel( .launchIn(viewModelScope) } + private fun reselectApks() { + viewModelScope.launch(ioDispatcher) { + val apps = synchronized(appsLock) { currentAppsByName } + apps.forEach { (appName, app) -> + val apks = app.apks as? ApksResult.Success ?: return@forEach + val selectedKey = selectPublishedApk(apks.apks)?.uniqueKey + if (selectedKey == apks.selectedApkKey) return@forEach + // Skip cards whose APKs changed meanwhile; they were selected for the new ABI already. + updateAppEverywhere(appName) { current -> + val currentApks = current.apks as? ApksResult.Success + if (currentApks?.apks == apks.apks) current.copy(apks = currentApks.copy(selectedApkKey = selectedKey)) else current + } + } + } + } + fun initialLoad() { if (initialLoadStarted) return initialLoadStarted = true @@ -365,7 +379,6 @@ class HomeViewModel( isDownloadingAnyFile = false, selectedAppNames = selectedHomeAppNames, homeScreenLayout = homeScreenLayout, - preferredAbi = preferredAbi, ).applyDownloadStates(downloadStates.value) } @@ -426,7 +439,7 @@ class HomeViewModel( splitNames = appState?.splitNames.orEmpty(), activeAbi = appState?.activeAbi, installedTryBuild = appState?.takeIf { appName == FENIX_DEBUG }?.let(::matchingInstalledTryBuild), - apks = ApksResult.Success(apks.map { it.toUiModel() }), + apks = cachedCardApks(apks.map { it.toUiModel() }), selectedReleaseVersion = selectedReleaseVersion, availableReleaseVersions = availableReleaseVersions, ) @@ -508,6 +521,30 @@ class HomeViewModel( ) } + /** Turns a build's parsed APKs into the card's APKs, selecting the one the card offers. */ + private suspend fun toCardApks(parsedApks: List): ApksResult.Success { + val apks = convertParsedApksToUiModels(parsedApks) + return ApksResult.Success(apks, selectPublishedApk(apks)?.uniqueKey) + } + + /** Selects cached APKs without a request; the refresh that follows checks the selection. */ + private fun cachedCardApks(apks: List): ApksResult.Success = + ApksResult.Success(apks, candidatesByPreference(apks).firstOrNull()?.uniqueKey) + + /** The build's APKs for this device, in [abiPreferenceOrder]. */ + private fun candidatesByPreference(apks: List): List = + abiOrder.mapNotNull { abi -> + apks.firstOrNull { it.abi.name.equals(abi, ignoreCase = true) } + } + + /** + * The first [candidatesByPreference] APK that's actually published. Archive APK URLs are built from + * directory names, which don't guarantee the file exists (e.g. releases before 143 only publish + * an `.aab` in the universal directory). Usually one request. + */ + private suspend fun selectPublishedApk(apks: List): ApkUiModel? = + candidatesByPreference(apks).firstOrNull { mozillaArchiveRepository.isPublished(it.url) } + private fun convertParsedApksToUiModels(parsedApks: List): List { return parsedApks.map { parsedApk -> val buildDate = parsedApk.rawDateString?.rawNightlyBuildDate() @@ -618,23 +655,10 @@ class HomeViewModel( val builds = pendingBuildsByApp.remove(appName) ?: return val chosen = builds.filter { it.rawDateString == buildId } if (chosen.isEmpty()) return - val chosenApks = ApksResult.Success(convertParsedApksToUiModels(chosen)) - - updateCurrentApp(appName) { - it.copy(apks = chosenApks, pendingBuildOptions = emptyList()) - } - - _homeScreenState.update { state -> - if (state !is HomeScreenState.Loaded) return@update state - val app = state.apps[appName] ?: return@update state - state.copy( - apps = state.apps + ( - appName to app.copy( - apks = chosenApks, - pendingBuildOptions = emptyList(), - ) - ), - ) + // Checking the chosen build is published needs a request, so apply it off the main thread. + viewModelScope.launch(ioDispatcher) { + val chosenApks = toCardApks(chosen) + updateAppEverywhere(appName) { it.copy(apks = chosenApks, pendingBuildOptions = emptyList()) } } } @@ -697,6 +721,15 @@ class HomeViewModel( } } + private fun updateAppEverywhere(appName: String, transform: (AppUiModel) -> AppUiModel) { + updateCurrentApp(appName, transform) + _homeScreenState.update { state -> + if (state !is HomeScreenState.Loaded) return@update state + val app = state.apps[appName] ?: return@update state + state.copy(apps = state.apps + (appName to transform(app))) + } + } + private fun updateDate( appName: String, date: LocalDate?, @@ -735,7 +768,7 @@ class HomeViewModel( buildOptions = options } val latestApks = getLatestApks(result.data) - ApksResult.Success(convertParsedApksToUiModels(latestApks)) + toCardApks(latestApks) } is NetworkResult.Error -> ApksResult.Error( @@ -815,11 +848,11 @@ class HomeViewModel( } } - private fun NetworkResult>.toApksResult(appName: String): ApksResult { + private suspend fun NetworkResult>.toApksResult(appName: String): ApksResult { return when (this) { is NetworkResult.Success -> { val latestApks = getLatestApks(data) - ApksResult.Success(convertParsedApksToUiModels(latestApks)) + toCardApks(latestApks) } is NetworkResult.Error -> ApksResult.Error( @@ -858,7 +891,7 @@ class HomeViewModel( val updatedApks = apksResult.apks.map { apk -> apk.copy(downloadState = resolveDownloadState(apk, persistedDownloads)) } - return copy(apks = ApksResult.Success(updatedApks)) + return copy(apks = apksResult.copy(apks = updatedApks)) } private fun AppUiModel.containsActiveDownload(): Boolean = diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt index df5fb1a..379ba8f 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt @@ -13,7 +13,7 @@ import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.HomeScreenLayout -import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI +import org.mozilla.tryfox.util.UNIVERSAL_ABI import org.mozilla.tryfox.util.abiPreferenceOptions import org.mozilla.tryfox.util.resolvePreferredAbi @@ -22,8 +22,8 @@ data class SettingsUiState( val cacheSizeBytes: Long = 0L, val hasActiveDownloads: Boolean = false, val homeScreenLayout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, - val preferredAbi: String = DEFAULT_PREFERRED_ABI, - val abiOptions: List = listOf(DEFAULT_PREFERRED_ABI), + val preferredAbi: String = UNIVERSAL_ABI, + val abiOptions: List = listOf(UNIVERSAL_ABI), ) { val canClearCache: Boolean get() = cacheState == CacheManagementState.IdleNonEmpty && !hasActiveDownloads @@ -52,7 +52,11 @@ class SettingsViewModel( preferredAbi = resolvePreferredAbi(preferredAbi, supportedAbis), abiOptions = abiOptions, ) - }.stateIn(viewModelScope, SharingStarted.Eagerly, SettingsUiState(abiOptions = abiOptions)) + }.stateIn( + viewModelScope, + SharingStarted.Eagerly, + SettingsUiState(preferredAbi = resolvePreferredAbi(null, supportedAbis), abiOptions = abiOptions), + ) init { viewModelScope.launch { cacheManager.checkCacheStatus() } diff --git a/app/src/main/java/org/mozilla/tryfox/util/Abi.kt b/app/src/main/java/org/mozilla/tryfox/util/Abi.kt index 261d774..842646a 100644 --- a/app/src/main/java/org/mozilla/tryfox/util/Abi.kt +++ b/app/src/main/java/org/mozilla/tryfox/util/Abi.kt @@ -7,15 +7,46 @@ private val LEGACY_ABIS = setOf("armeabi", "mips", "mips64") /** The ABI name the archive uses for the APK that bundles every architecture. */ const val UNIVERSAL_ABI = "universal" -/** The ABI affinity used until the user picks one: universal installs on any device. */ -const val DEFAULT_PREFERRED_ABI = UNIVERSAL_ABI - fun isUniversalAbi(abiName: String?): Boolean = UNIVERSAL_ABI.equals(abiName, ignoreCase = true) /** True when an APK built for [abiName] can be installed on a device running [deviceAbis]. */ fun isAbiSupported(abiName: String?, deviceAbis: List): Boolean = isUniversalAbi(abiName) || deviceAbis.any { it.equals(abiName, ignoreCase = true) } +// Which APK to offer is decided by a cascade, each step building on the one before: +// deviceAbiOptions -> the device's ABIs that APKs are built for +// abiPreferenceOptions -> those plus universal: the choices offered in Settings +// resolvePreferredAbi -> the stored choice if it's an option, else the device's primary ABI +// abiPreferenceOrder -> that choice, then universal, then the device's other ABIs + +/** Every ABI the device runs that an APK could plausibly be built for, in the device's order. */ +fun deviceAbiOptions(deviceAbis: List): List = + deviceAbis.filterNot { isUniversalAbi(it) || it.lowercase() in LEGACY_ABIS }.distinct() + +/** The affinity options offered in Settings: universal first, then [deviceAbiOptions]. */ +fun abiPreferenceOptions(deviceAbis: List): List = listOf(UNIVERSAL_ABI) + deviceAbiOptions(deviceAbis) + +/** + * The stored [preferredAbi] if it's still an option on this device; otherwise the device's primary + * ABI, or universal if the device reports none we build for. This favors smaller downloads over + * universal APKs unless the user explicitly picks universal. + */ +fun resolvePreferredAbi(preferredAbi: String?, deviceAbis: List): String { + val options = abiPreferenceOptions(deviceAbis) + return preferredAbi + ?.takeIf { options.any { option -> option.equals(it, ignoreCase = true) } } + ?: deviceAbiOptions(deviceAbis).firstOrNull() + ?: UNIVERSAL_ABI +} + +/** + * The ABIs a build's APK is picked from, best first: the resolved preference, then universal (it + * installs anywhere), then the device's other ABIs in its own order. ABIs the device can't run are + * never offered. + */ +fun abiPreferenceOrder(preferredAbi: String?, deviceAbis: List): List = + (listOf(resolvePreferredAbi(preferredAbi, deviceAbis), UNIVERSAL_ABI) + deviceAbiOptions(deviceAbis)).distinct() + /** * Determine the active ABI for installed package from `ApplicationInfo.nativeLibraryDir` path. * @@ -39,21 +70,3 @@ fun abiFromNativeLibraryDir(nativeLibraryDir: String?): String? { ?.takeIf { it.isNotBlank() && it != "lib" } ?.let { directory -> directoryAbis[directory] ?: directory } } - -/** - * The affinity options offered in Settings: universal first, then every ABI the device runs that - * an APK could plausibly be built for. - */ -fun abiPreferenceOptions(deviceAbis: List): List { - val filteredAbis = deviceAbis - .filterNot { isUniversalAbi(it) || it.lowercase() in LEGACY_ABIS } - .distinct() - return listOf(UNIVERSAL_ABI) + filteredAbis -} - -/** Falls back to universal when the stored ABI is blank or no longer supported by this device. */ -fun resolvePreferredAbi(preferredAbi: String?, deviceAbis: List): String = - preferredAbi - ?.takeIf { it.isNotBlank() } - ?.takeIf { abiPreferenceOptions(deviceAbis).any { option -> option.equals(it, ignoreCase = true) } } - ?: DEFAULT_PREFERRED_ABI diff --git a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt index 470ad59..494c7d3 100644 --- a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt @@ -540,7 +540,7 @@ class FenixReleaseTest { } @Test - fun `nightly listing yields the latest day's builds`() { + 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( "../", @@ -553,12 +553,11 @@ class FenixReleaseTest { val latest = parser.parseNightlyBuildsFromHtml(html, listingUrl, date = null) - // Universal nightly directories are skipped until missing APKs can be detected. - assertEquals(listOf("arm64-v8a", "x86_64"), latest.map { it.abiName }) + 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-arm64-v8a/fenix-159.0a1.multi.android-arm64-v8a.apk", - latest.single { it.abiName == "arm64-v8a" }.fullUrl, + "${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"), 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 ed2c413..eb3b9d8 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/FakeMozillaArchiveRepository.kt @@ -19,7 +19,9 @@ class FakeMozillaArchiveRepository( private val focusBetaReleases: NetworkResult> = NetworkResult.Success(emptyList()), private val focusBetaVersions: NetworkResult> = NetworkResult.Success(emptyList()), private val focusBetaReleasesByVersion: Map>> = emptyMap(), + private val unpublishedUrls: Set = emptySet(), ) : MozillaArchiveRepository { + val publishedChecks = mutableListOf() override suspend fun getFenixNightlyBuilds(date: LocalDate?): NetworkResult> { return fenixBuilds @@ -62,4 +64,9 @@ class FakeMozillaArchiveRepository( override suspend fun getFocusBetaBuildsForVersion(version: String): NetworkResult> = focusBetaReleasesByVersion[version] ?: focusBetaReleases + + override suspend fun isPublished(url: String): Boolean { + publishedChecks += url + return url !in unpublishedUrls + } } diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt index 3897376..a354e5d 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt @@ -8,7 +8,6 @@ import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.lan.LanReceiveIdentity import org.mozilla.tryfox.model.HomeScreenLayout -import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI /** * A fake implementation of [UserDataRepository] for testing purposes. @@ -23,8 +22,8 @@ class FakeUserDataRepository : UserDataRepository { override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow private val _homeScreenLayoutFlow = MutableStateFlow(HomeScreenLayout.OneCardPerApp) override val homeScreenLayoutFlow: Flow = _homeScreenLayoutFlow - private val _preferredAbiFlow = MutableStateFlow(DEFAULT_PREFERRED_ABI) - override val preferredAbiFlow: Flow = _preferredAbiFlow + private val _preferredAbiFlow = MutableStateFlow(null) + override val preferredAbiFlow: Flow = _preferredAbiFlow override suspend fun saveLastSearchedEmail(email: String) { recordSearch("try", email) 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 9a8db65..229c988 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 @@ -48,6 +48,7 @@ import org.mozilla.tryfox.data.repositories.FocusReleaseRepository import org.mozilla.tryfox.data.repositories.HomeDataCacheRepository import org.mozilla.tryfox.data.repositories.HomeDataSnapshot import org.mozilla.tryfox.data.repositories.InstalledTryBuildRepository +import org.mozilla.tryfox.data.repositories.MozillaArchiveRepository import org.mozilla.tryfox.data.repositories.ReleaseRepository import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.download.ApkDownloadRequest @@ -61,7 +62,6 @@ import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult -import org.mozilla.tryfox.ui.models.preferredAbiApk import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_DEBUG import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE @@ -205,6 +205,7 @@ class HomeViewModelTest { userDataRepository: FakeUserDataRepository? = null, homeDataCacheRepository: HomeDataCacheRepository = FakeHomeDataCacheRepository(), installedTryBuildRepository: InstalledTryBuildRepository = FakeInstalledTryBuildRepository(), + mozillaArchiveRepository: MozillaArchiveRepository = FakeMozillaArchiveRepository(), ) = HomeViewModel( releaseRepositories = releaseRepositories, downloadCoordinator = fakeDownloadCoordinator, @@ -216,6 +217,7 @@ class HomeViewModelTest { userDataRepository = userDataRepository, homeDataCacheRepository = homeDataCacheRepository, installedTryBuildRepository = installedTryBuildRepository, + mozillaArchiveRepository = mozillaArchiveRepository, supportedAbis = listOf("arm64-v8a", "x86_64", "armeabi-v7a"), ) @@ -234,7 +236,7 @@ class HomeViewModelTest { } @Test - fun `preferred ABI reaches the loaded state and universal APKs count as supported`() = runTest { + fun `preferred ABI defaults to the device's primary ABI and universal APKs count as supported`() = runTest { val universalApk = createTestParsedReleaseApk(version = "153.0", abi = UNIVERSAL_ABI) val arm64Apk = createTestParsedReleaseApk(version = "153.0", abi = "arm64-v8a") val userDataRepository = FakeUserDataRepository() @@ -255,19 +257,18 @@ class HomeViewModelTest { viewModel.initialLoad() advanceUntilIdle() - val initialState = viewModel.homeScreenState.value as HomeScreenState.Loaded - assertEquals(UNIVERSAL_ABI, initialState.preferredAbi) - val apks = (initialState.apps.getValue(FENIX_RELEASE).apks as ApksResult.Success).apks + fun cardApks() = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps.getValue(FENIX_RELEASE).apks as ApksResult.Success // Universal bundles every architecture, so it must never be flagged as unsupported. - assertTrue(apks.single { it.abi.name == UNIVERSAL_ABI }.abi.isSupported) - assertEquals(UNIVERSAL_ABI, apks.preferredAbiApk(initialState.preferredAbi)?.abi?.name) + assertTrue(cardApks().apks.single { it.abi.name == UNIVERSAL_ABI }.abi.isSupported) + // By default the card offers the device's primary ABI. + assertEquals("arm64-v8a", cardApks().selectedApk?.abi?.name) - userDataRepository.savePreferredAbi("arm64-v8a") + userDataRepository.savePreferredAbi(UNIVERSAL_ABI) advanceUntilIdle() - val updatedState = viewModel.homeScreenState.value as HomeScreenState.Loaded - assertEquals("arm64-v8a", updatedState.preferredAbi) - assertEquals("arm64-v8a", apks.preferredAbiApk(updatedState.preferredAbi)?.abi?.name) + assertEquals(UNIVERSAL_ABI, cardApks().selectedApk?.abi?.name) + // Selection doesn't reorder or drop the build's APKs. + assertEquals(listOf("arm64-v8a", UNIVERSAL_ABI), cardApks().apks.map { it.abi.name }) } private class FakeHomeDataCacheRepository( @@ -847,6 +848,152 @@ class HomeViewModelTest { assertTrue(candidate.uniqueKey != stableKey) } + @Test + fun `card skips an unpublished universal APK and falls back to a device ABI`() = runTest { + // Archive listing order; the device (arm64-v8a, x86_64, armeabi-v7a) prefers x86_64 over armeabi-v7a. + val apks = listOf("armeabi-v7a", "x86_64", UNIVERSAL_ABI).map { createTestParsedReleaseApk(version = "140.0.4", abi = it) } + val universalUrl = apks.single { it.abiName == UNIVERSAL_ABI }.fullUrl + val x86Url = apks.single { it.abiName == "x86_64" }.fullUrl + val archive = FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("140.0.4")), + fenixReleasesByVersion = mapOf("140.0.4" to NetworkResult.Success(apks)), + unpublishedUrls = setOf(universalUrl), + ) + viewModel = createViewModel( + releaseRepositories = listOf(FenixReleaseReleaseRepository(archive)), + mozillaArchiveRepository = archive, + ) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + + viewModel.initialLoad() + advanceUntilIdle() + + val cardApks = fenixReleaseApp().apks as ApksResult.Success + assertEquals(listOf(universalUrl, x86Url), archive.publishedChecks) + val selected = cardApks.selectedApk!! + assertEquals("x86_64", selected.abi.name) + assertEquals(3, cardApks.apks.size) + + viewModel.downloadNightlyApk(selected) + advanceUntilIdle() + + assertEquals(x86Url, fakeDownloadCoordinator.enqueuedRequests.single().downloadUrl) + assertEquals(2, archive.publishedChecks.size) + } + + @Test + fun `card checks a published preferred APK once and keeps every variant`() = runTest { + val apks = listOf("arm64-v8a", UNIVERSAL_ABI).map { createTestParsedReleaseApk(version = "157.0.1", abi = it) } + val archive = FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("157.0.1")), + fenixReleasesByVersion = mapOf("157.0.1" to NetworkResult.Success(apks)), + ) + viewModel = createViewModel( + releaseRepositories = listOf(FenixReleaseReleaseRepository(archive)), + mozillaArchiveRepository = archive, + ) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + + viewModel.initialLoad() + advanceUntilIdle() + + // The device's primary ABI comes first by default, so it's the one checked. + assertEquals(listOf(apks.single { it.abiName == "arm64-v8a" }.fullUrl), archive.publishedChecks) + assertEquals(2, (fenixReleaseApp().apks as ApksResult.Success).apks.size) + } + + @Test + fun `a cold start from saved cards checks each card's APK once`() = runTest { + val apk = createTestParsedReleaseApk(version = "157.0.1", abi = "arm64-v8a") + val cache = FakeHomeDataCacheRepository( + HomeDataSnapshot( + version = HomeDataSnapshot.CURRENT_VERSION, + apps = listOf( + CachedHomeApp( + appName = FENIX_RELEASE, + apks = listOf( + CachedHomeApk(apk.originalString, apk.rawDateString, apk.appName, apk.version, apk.abiName, apk.fullUrl, apk.fileName), + ), + ), + ), + ), + ) + val archive = FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("157.0.1")), + fenixReleasesByVersion = mapOf("157.0.1" to NetworkResult.Success(listOf(apk))), + ) + viewModel = createViewModel( + releaseRepositories = listOf(FenixReleaseReleaseRepository(archive)), + // Emits the stored (unset) preference on start, as the real repository does. + userDataRepository = FakeUserDataRepository(), + homeDataCacheRepository = cache, + mozillaArchiveRepository = archive, + ) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + + viewModel.initialLoad() + advanceUntilIdle() + + assertEquals(listOf(apk.fullUrl), archive.publishedChecks) + } + + @Test + fun `picking a version checks that version's preferred APK`() = runTest { + val latest = listOf("arm64-v8a", UNIVERSAL_ABI).map { createTestParsedReleaseApk(version = "157.0.1", abi = it) } + val older = listOf("arm64-v8a", UNIVERSAL_ABI).map { createTestParsedReleaseApk(version = "140.0.4", abi = it) } + val archive = FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("157.0.1", "140.0.4")), + fenixReleasesByVersion = mapOf( + "157.0.1" to NetworkResult.Success(latest), + "140.0.4" to NetworkResult.Success(older), + ), + unpublishedUrls = setOf(older.single { it.abiName == UNIVERSAL_ABI }.fullUrl), + ) + val userDataRepository = FakeUserDataRepository().apply { savePreferredAbi(UNIVERSAL_ABI) } + viewModel = createViewModel( + releaseRepositories = listOf(FenixReleaseReleaseRepository(archive)), + userDataRepository = userDataRepository, + mozillaArchiveRepository = archive, + ) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + viewModel.initialLoad() + advanceUntilIdle() + + viewModel.onReleaseVersionSelected(FENIX_RELEASE, "140.0.4") + advanceUntilIdle() + + assertEquals("arm64-v8a", (fenixReleaseApp().apks as ApksResult.Success).selectedApk?.abi?.name) + } + + @Test + fun `changing the preferred ABI checks the variant each card now uses`() = runTest { + val apks = listOf("arm64-v8a", "x86_64", UNIVERSAL_ABI).map { createTestParsedReleaseApk(version = "140.0.4", abi = it) } + val x86Url = apks.single { it.abiName == "x86_64" }.fullUrl + val userDataRepository = FakeUserDataRepository() + val archive = FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("140.0.4")), + fenixReleasesByVersion = mapOf("140.0.4" to NetworkResult.Success(apks)), + unpublishedUrls = setOf(x86Url), + ) + viewModel = createViewModel( + releaseRepositories = listOf(FenixReleaseReleaseRepository(archive)), + userDataRepository = userDataRepository, + mozillaArchiveRepository = archive, + ) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + viewModel.initialLoad() + advanceUntilIdle() + assertEquals(3, (fenixReleaseApp().apks as ApksResult.Success).apks.size) + + userDataRepository.savePreferredAbi("x86_64") + advanceUntilIdle() + + assertTrue(x86Url in archive.publishedChecks) + assertEquals(UNIVERSAL_ABI, (fenixReleaseApp().apks as ApksResult.Success).selectedApk?.abi?.name) + } + + private fun fenixReleaseApp() = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps[FENIX_RELEASE]!! + @Test fun `onReleaseVersionSelected should reload Focus APKs for selected version`() = runTest { val latestReleaseApk = createTestParsedReleaseApk(version = "147.0.1", appName = testFocusReleaseAppName) diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt index 13e76eb..b011593 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt @@ -63,7 +63,7 @@ class SettingsViewModelTest { } @Test - fun `preferred ABI defaults to universal and offers every device ABI`() = runTest { + fun `preferred ABI defaults to the device's primary ABI and offers every device ABI`() = runTest { val userData = FakeUserDataRepository() val viewModel = SettingsViewModel( FakeCacheManager(tempDir), @@ -73,21 +73,21 @@ class SettingsViewModelTest { ) advanceUntilIdle() - assertEquals(UNIVERSAL_ABI, viewModel.uiState.value.preferredAbi) + assertEquals("arm64-v8a", viewModel.uiState.value.preferredAbi) assertEquals(listOf(UNIVERSAL_ABI, "arm64-v8a", "armeabi-v7a"), viewModel.uiState.value.abiOptions) - viewModel.selectPreferredAbi("arm64-v8a") + viewModel.selectPreferredAbi(UNIVERSAL_ABI) advanceUntilIdle() - assertEquals("arm64-v8a", viewModel.uiState.value.preferredAbi) + assertEquals(UNIVERSAL_ABI, viewModel.uiState.value.preferredAbi) // An ABI this device cannot run is never persisted. viewModel.selectPreferredAbi("x86_64") advanceUntilIdle() - assertEquals("arm64-v8a", viewModel.uiState.value.preferredAbi) + assertEquals(UNIVERSAL_ABI, viewModel.uiState.value.preferredAbi) } @Test - fun `a stored ABI the device no longer runs resolves back to universal`() = runTest { + fun `a stored ABI the device no longer runs resolves back to the device default`() = runTest { val userData = FakeUserDataRepository() userData.savePreferredAbi("x86_64") val viewModel = SettingsViewModel( @@ -98,7 +98,7 @@ class SettingsViewModelTest { ) advanceUntilIdle() - assertEquals(UNIVERSAL_ABI, viewModel.uiState.value.preferredAbi) + assertEquals("arm64-v8a", viewModel.uiState.value.preferredAbi) } private class FakeDownloadCoordinator : ApkDownloadCoordinator { diff --git a/app/src/test/java/org/mozilla/tryfox/util/AbiPreferenceTest.kt b/app/src/test/java/org/mozilla/tryfox/util/AbiPreferenceTest.kt index c734b20..008fe16 100644 --- a/app/src/test/java/org/mozilla/tryfox/util/AbiPreferenceTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/util/AbiPreferenceTest.kt @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Test import org.mozilla.tryfox.data.DownloadState import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel -import org.mozilla.tryfox.ui.models.preferredAbiApk import java.io.File class AbiPreferenceTest { @@ -36,15 +35,53 @@ class AbiPreferenceTest { } @Test - fun `stored affinity falls back to universal when blank or foreign to this device`() { - assertEquals("arm64-v8a", resolvePreferredAbi("arm64-v8a", deviceAbis)) + fun `preference order is the resolved preference, then universal, then device ABIs in device order`() { + val emulatorAbis = listOf("x86_64", "arm64-v8a") + + assertEquals(listOf(UNIVERSAL_ABI, "x86_64", "arm64-v8a"), abiPreferenceOrder(UNIVERSAL_ABI, emulatorAbis)) + assertEquals(listOf("arm64-v8a", UNIVERSAL_ABI, "x86_64"), abiPreferenceOrder("arm64-v8a", emulatorAbis)) + // Unset, the device's primary ABI comes first. + assertEquals(listOf("x86_64", UNIVERSAL_ABI, "arm64-v8a"), abiPreferenceOrder(null, emulatorAbis)) + // ABIs the device can't run, and retired ones, are never offered. + assertEquals(listOf("arm64-v8a", UNIVERSAL_ABI), abiPreferenceOrder(null, listOf("arm64-v8a", "armeabi"))) + } + + @Test + fun `stored affinity falls back to the device's primary ABI when unset or foreign to this device`() { + assertEquals("armeabi-v7a", resolvePreferredAbi("armeabi-v7a", deviceAbis)) + // An explicit universal choice is kept. assertEquals(UNIVERSAL_ABI, resolvePreferredAbi(UNIVERSAL_ABI, deviceAbis)) - assertEquals(UNIVERSAL_ABI, resolvePreferredAbi(null, deviceAbis)) - assertEquals(UNIVERSAL_ABI, resolvePreferredAbi("", deviceAbis)) + assertEquals("arm64-v8a", resolvePreferredAbi(null, deviceAbis)) + assertEquals("arm64-v8a", resolvePreferredAbi("", deviceAbis)) // Recorded on an x86 emulator, then restored onto an ARM device. - assertEquals(UNIVERSAL_ABI, resolvePreferredAbi("x86_64", deviceAbis)) - // The device runs armeabi, but it is no longer an option, so it resolves back to universal. - assertEquals(UNIVERSAL_ABI, resolvePreferredAbi("armeabi", deviceAbis)) + assertEquals("arm64-v8a", resolvePreferredAbi("x86_64", deviceAbis)) + // The device runs armeabi, but it is no longer an option, so it resolves to the default. + assertEquals("arm64-v8a", resolvePreferredAbi("armeabi", deviceAbis)) + } + + @Test + fun `unset preference resolves to the device's first usable ABI`() { + assertEquals("x86_64", resolvePreferredAbi(null, listOf("x86_64", "arm64-v8a"))) + // Nothing buildable is reported, so fall back to universal, which installs anywhere. + assertEquals(UNIVERSAL_ABI, resolvePreferredAbi(null, listOf("armeabi"))) + assertEquals(UNIVERSAL_ABI, resolvePreferredAbi(null, emptyList())) + } + + @Test + fun `cascade builds each list from the one before`() { + // An x86_64 emulator that also runs ARM code, and advertises a retired ABI. + val emulatorAbis = listOf("x86_64", "arm64-v8a", "armeabi") + + assertEquals(listOf("x86_64", "arm64-v8a"), deviceAbiOptions(emulatorAbis)) + assertEquals(listOf(UNIVERSAL_ABI, "x86_64", "arm64-v8a"), abiPreferenceOptions(emulatorAbis)) + + // Nothing stored: the device's primary ABI leads, then universal, then the rest. + assertEquals("x86_64", resolvePreferredAbi(null, emulatorAbis)) + assertEquals(listOf("x86_64", UNIVERSAL_ABI, "arm64-v8a"), abiPreferenceOrder(null, emulatorAbis)) + + // An explicit choice leads instead, without repeating it later in the order. + assertEquals("arm64-v8a", resolvePreferredAbi("arm64-v8a", emulatorAbis)) + assertEquals(listOf("arm64-v8a", UNIVERSAL_ABI, "x86_64"), abiPreferenceOrder("arm64-v8a", emulatorAbis)) } @Test @@ -64,27 +101,32 @@ class AbiPreferenceTest { @Test fun `preferred variant wins and universal is the fallback`() { - val releaseApks = listOf(apk("arm64-v8a"), apk("armeabi-v7a"), apk(UNIVERSAL_ABI)) + val releaseAbis = listOf("arm64-v8a", "armeabi-v7a", UNIVERSAL_ABI) - assertEquals(UNIVERSAL_ABI, releaseApks.preferredAbiApk(UNIVERSAL_ABI)?.abi?.name) - assertEquals("arm64-v8a", releaseApks.preferredAbiApk("arm64-v8a")?.abi?.name) - // x86_64 has no variant here, so the universal APK still installs. - assertEquals(UNIVERSAL_ABI, releaseApks.preferredAbiApk("x86_64")?.abi?.name) + assertEquals(UNIVERSAL_ABI, releaseAbis.pickedFor(UNIVERSAL_ABI)) + assertEquals("arm64-v8a", releaseAbis.pickedFor("arm64-v8a")) + // x86_64 isn't an option on this device, so the device's primary ABI is used instead. + assertEquals("arm64-v8a", releaseAbis.pickedFor("x86_64")) + // A build without the preferred variant falls back to universal. + assertEquals(UNIVERSAL_ABI, listOf("armeabi-v7a", UNIVERSAL_ABI).pickedFor("arm64-v8a")) } @Test - fun `nightly builds without a universal variant fall back to a supported ABI`() { - val nightlyApks = listOf( - apk("x86_64", isSupported = false), - apk("arm64-v8a"), - ) - - assertEquals("arm64-v8a", nightlyApks.preferredAbiApk(UNIVERSAL_ABI)?.abi?.name) - assertEquals("arm64-v8a", nightlyApks.preferredAbiApk("arm64-v8a")?.abi?.name) - assertEquals("x86_64", nightlyApks.preferredAbiApk("x86_64")?.abi?.name) - assertNull(emptyList().preferredAbiApk(UNIVERSAL_ABI)) + fun `builds without a universal variant fall back to a supported ABI`() { + // e.g. a Focus build, whose universal APK isn't published. + val abis = listOf("x86_64", "arm64-v8a") + + assertEquals("arm64-v8a", abis.pickedFor(UNIVERSAL_ABI)) + assertEquals("arm64-v8a", abis.pickedFor("arm64-v8a")) + // The x86_64 APK can't run on this ARM device, so it's never picked. + assertEquals("arm64-v8a", abis.pickedFor("x86_64")) + assertNull(listOf("x86_64").pickedFor(UNIVERSAL_ABI)) } + /** The ABI from this build that [abiPreferenceOrder] picks first, as the home card does. */ + private fun List.pickedFor(preferredAbi: String): String? = + abiPreferenceOrder(preferredAbi, deviceAbis).firstOrNull { it in this } + private fun apk(abiName: String, isSupported: Boolean = true) = ApkUiModel( originalString = "fenix-153.0-android-$abiName/", date = "",