diff --git a/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCardHeaderLayoutTest.kt b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCardHeaderLayoutTest.kt index 4296b85..7e72b01 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCardHeaderLayoutTest.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCardHeaderLayoutTest.kt @@ -1,9 +1,12 @@ package org.mozilla.tryfox.ui.composables import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag import androidx.compose.ui.test.onNodeWithTag +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 import kotlinx.datetime.LocalDate import org.junit.Assert.assertTrue @@ -14,7 +17,10 @@ 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.theme.TryFoxTheme +import org.mozilla.tryfox.util.FENIX_BETA +import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_RELEASE import java.io.File @@ -113,6 +119,139 @@ class ArchiveGroupCardHeaderLayoutTest { ) } + @Test + fun releaseVersionSelector_opensAtSelectedMajorAndConfirmsChosenVariant() { + var confirmedVersion: String? = null + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + ArchiveGroupCard( + apks = listOf(createApkUiModel(FOCUS_RELEASE, "151.0.1", "")), + onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, + appState = null, onDateSelected = {}, userPickedDate = null, + selectedReleaseVersion = "151.0.1", + availableReleaseVersions = listOf("151.0.1", "151.0.0", "150.0.1"), + appName = FOCUS_RELEASE, errorMessage = null, isLoading = false, + dateValidator = { true }, onClearDate = {}, + onReleaseVersionSelected = { confirmedVersion = it }, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_selector_sheet_focus-release", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Version").assertIsDisplayed() + composeTestRule.onNodeWithText("Builds:").assertIsDisplayed() + composeTestRule.onNodeWithTag("release_version_variant_151_0_1", useUnmergedTree = true).assertIsSelected() + + composeTestRule.onNodeWithTag("release_version_major_picker_focus-release", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithTag("release_version_variant_151_0_0", useUnmergedTree = true).performClick() + + assertTrue(confirmedVersion == "151.0.0") + assertTrue( + composeTestRule + .onAllNodesWithTag("release_version_selector_sheet_focus-release", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty(), + ) + } + + @Test + fun betaVersionSelector_filtersVariantsAndCancelKeepsSelection() { + var confirmedVersion: String? = null + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + ArchiveGroupCard( + apks = listOf(createApkUiModel(FOCUS_BETA, "151.0b2", "")), + onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, + appState = null, onDateSelected = {}, userPickedDate = null, + selectedReleaseVersion = "151.0b2", + availableReleaseVersions = listOf("151.0b2", "151.0b1", "150.0b3"), + appName = FOCUS_BETA, errorMessage = null, isLoading = false, + dateValidator = { true }, onClearDate = {}, + onReleaseVersionSelected = { confirmedVersion = it }, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_focus-beta", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_variant_151_0b2", useUnmergedTree = true).assertIsSelected() + assertTrue( + composeTestRule + .onAllNodesWithTag("release_version_variant_150_0b3", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty(), + ) + + composeTestRule.onNodeWithTag("release_version_major_picker_focus-beta", useUnmergedTree = true).assertIsDisplayed() + composeTestRule.onNodeWithText("Cancel").performClick() + + assertTrue(confirmedVersion == null) + } + + @Test + fun versionSelector_supportsTheHighestMajorProvidedByTheArchive() { + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + ArchiveGroupCard( + apks = listOf(createApkUiModel(FOCUS_RELEASE, "155.0.1", "")), + onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, + appState = null, onDateSelected = {}, userPickedDate = null, + selectedReleaseVersion = "155.0.1", + availableReleaseVersions = listOf("155.0.1", "154.0.2"), + appName = FOCUS_RELEASE, errorMessage = null, isLoading = false, + dateValidator = { true }, onClearDate = {}, onReleaseVersionSelected = {}, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_variant_155_0_1", useUnmergedTree = true).assertIsDisplayed() + } + + @Test + fun fenixReleaseSelector_showsCandidateVariantsAndConfirmsTheSelectedRc() { + var confirmedVersion: String? = null + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + ArchiveGroupCard( + apks = listOf(createApkUiModel(FENIX_RELEASE, "153.0.4", "")), + onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, + appState = null, onDateSelected = {}, userPickedDate = null, + selectedReleaseVersion = "153.0.4", + availableReleaseVersions = listOf("153.0.4", "153.0.4-RC2", "153.0.4-RC1"), + appName = FENIX_RELEASE, errorMessage = null, isLoading = false, + dateValidator = { true }, onClearDate = {}, + onReleaseVersionSelected = { confirmedVersion = it }, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).performClick() + + assertTrue(confirmedVersion == "153.0.4-RC2") + } + + @Test + fun fenixBetaSelector_showsCandidateVariants() { + composeTestRule.setContent { + TryFoxTheme(dynamicColor = false) { + ArchiveGroupCard( + apks = listOf(createApkUiModel(FENIX_BETA, "153.0b5", "")), + onDownloadClick = {}, onInstallClick = {}, onOpenAppClick = {}, onUninstallClick = {}, + appState = null, onDateSelected = {}, userPickedDate = null, + selectedReleaseVersion = "153.0b5", + availableReleaseVersions = listOf("153.0b5", "153.0b5-RC2", "153.0b5-RC1"), + appName = FENIX_BETA, errorMessage = null, isLoading = false, + dateValidator = { true }, onClearDate = {}, onReleaseVersionSelected = {}, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_fenix-beta", useUnmergedTree = true).performClick() + composeTestRule.onNodeWithTag("release_version_variant_153_0b5-RC2", useUnmergedTree = true).assertIsDisplayed() + } + private fun createApkUiModel( appName: String, version: String, 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 57cabe2..8f72ed2 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 @@ -26,6 +26,7 @@ import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE +import org.mozilla.tryfox.util.FOCUS_RELEASE import java.io.File @RunWith(AndroidJUnit4::class) @@ -207,4 +208,60 @@ class HomeAppCardTest { assertEquals(buildDate, selectedDate) } + + @Test + fun releaseVersionOnHomeCard_opensVersionSelectorSheet() { + val version = "151.0.1" + composeTestRule.setContent { + TryFoxTheme { + HomeAppCard( + card = HomeAppCardUiModel( + family = HomeAppFamily.Focus, + selectedAppName = FOCUS_RELEASE, + appsByName = mapOf( + FOCUS_RELEASE to AppUiModel( + name = FOCUS_RELEASE, + packageName = "org.mozilla.focus", + installedVersion = null, + installedDate = null, + selectedReleaseVersion = version, + availableReleaseVersions = listOf(version, "151.0.0", "150.0.1"), + apks = ApksResult.Success( + listOf( + ApkUiModel( + originalString = "", + date = "", + appName = FOCUS_RELEASE, + version = version, + abi = AbiUiModel("arm64-v8a", true), + url = "https://example.invalid/focus.apk", + fileName = "focus.apk", + uniqueKey = "focus-release/focus.apk", + apkDir = File("/tmp/focus"), + ), + ), + ), + ), + ), + ), + installStates = emptyMap(), + onFlavorSelected = {}, + onDownloadClick = {}, + onInstallClick = {}, + onOpenInstalledApp = {}, + onOpenTryBuild = { _, _ -> }, + onDateSelected = { _, _ -> }, + dateValidator = { true }, + onReleaseVersionSelected = { _, _ -> }, + onBuildSelected = { _, _ -> }, + onDismissBuildPicker = {}, + ) + } + } + + composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick() + composeTestRule + .onNodeWithTag("release_version_selector_sheet_focus-release", useUnmergedTree = true) + .assertIsDisplayed() + } } 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 083c8aa..8c9fc1d 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt @@ -56,6 +56,35 @@ 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("/") } + .filter { it.endsWith("-candidates") } + .map { it.removeSuffix("-candidates") } + .filter { candidate -> + when (releaseType) { + ReleaseType.Beta -> candidate.matches(Regex("\\d+\\.\\d+b\\d+")) + ReleaseType.Release -> isStableReleaseVersion(candidate) + } + } + .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() } + .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/ @@ -87,6 +116,19 @@ class MozillaArchiveHtmlParser { } internal fun compareReleaseVersions(version1: String, version2: String): Int { + val rc1 = parseCandidateDisplayVersion(version1) + val rc2 = parseCandidateDisplayVersion(version2) + if (rc1 != null || rc2 != null) { + val base1 = rc1?.first ?: version1 + val base2 = rc2?.first ?: version2 + val baseComparison = compareReleaseVersionsWithoutRc(base1, base2) + if (baseComparison != 0) return baseComparison + return (rc1?.second ?: Int.MAX_VALUE).compareTo(rc2?.second ?: Int.MAX_VALUE) + } + return compareReleaseVersionsWithoutRc(version1, version2) + } + + private fun compareReleaseVersionsWithoutRc(version1: String, version2: String): Int { val parts1 = version1.split(Regex("[.b-]")).mapNotNull { it.toIntOrNull() } val parts2 = version2.split(Regex("[.b-]")).mapNotNull { it.toIntOrNull() } @@ -101,6 +143,11 @@ class MozillaArchiveHtmlParser { return 0 } + private fun parseCandidateDisplayVersion(version: String): Pair? { + val match = Regex("^(.+)-RC(\\d+)$").matchEntire(version) ?: return null + return match.groupValues[1] to match.groupValues[2].toInt() + } + private fun isStableReleaseVersion(version: String): Boolean { val isPreRelease = version.contains(Regex("[ab]\\d+|beta|alpha|rc", RegexOption.IGNORE_CASE)) return !isPreRelease && version.matches(Regex("\\d+\\.\\d+(\\.\\d+)?")) 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 4fbc7f4..18533ca 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,5 +1,9 @@ package org.mozilla.tryfox.data.repositories +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.coroutineScope import kotlinx.datetime.Clock import kotlinx.datetime.DateTimeUnit import kotlinx.datetime.LocalDate @@ -26,8 +30,10 @@ class DefaultMozillaArchiveRepository( ) : MozillaArchiveRepository { companion object { + 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/" const val RELEASES_FOCUS_BASE_URL = "${ARCHIVE_MOZILLA_BASE_URL}focus/releases/" internal fun archiveUrlForDate(appName: String, date: LocalDate): String { @@ -44,6 +50,14 @@ class DefaultMozillaArchiveRepository( internal fun archiveUrlForRelease(baseUrl: String, number: String): String { return "$baseUrl$number/android/" } + + internal fun archiveUrlForCandidate(version: String, buildNumber: Int): String { + return "${CANDIDATES_FENIX_BASE_URL}$version-candidates/build$buildNumber/android/" + } + + internal fun archiveUrlForCandidateBuilds(version: String): String { + return "${CANDIDATES_FENIX_BASE_URL}$version-candidates/" + } } override suspend fun getFenixNightlyBuilds(date: LocalDate?): NetworkResult> = getNightlyBuilds(FENIX, date) @@ -107,12 +121,19 @@ class DefaultMozillaArchiveRepository( return try { val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FENIX_BASE_URL) val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, releaseType) + val candidateVersions = fetchFenixCandidateVersions(releaseType) + val versions = (releaseVersions + candidateVersions) + .distinct() + .sortedWith(mozillaArchiveHtmlParser::compareReleaseVersions) + .reversed() - if (releaseVersions.isEmpty()) { + if (versions.isEmpty()) { return NetworkResult.Error("No release versions found for type $releaseType", null) } - NetworkResult.Success(releaseVersions) + NetworkResult.Success(versions) + } catch (e: CancellationException) { + throw e } catch (e: Exception) { NetworkResult.Error("Failed to fetch Fenix release versions: ${e.message}", e) } @@ -150,13 +171,25 @@ class DefaultMozillaArchiveRepository( return NetworkResult.Error("No version provided", null) } - fetchReleaseApksForVersion( - version = version, - archiveBaseUrl = RELEASES_FENIX_BASE_URL, - archiveAppName = FENIX, - resultAppName = if (releaseType == ReleaseType.Release) FENIX_RELEASE else FENIX_BETA, - releaseType = releaseType, - ) + val candidate = parseCandidateVersion(version) + if (candidate == null) { + fetchReleaseApksForVersion( + version = version, + archiveBaseUrl = RELEASES_FENIX_BASE_URL, + archiveAppName = FENIX, + resultAppName = if (releaseType == ReleaseType.Release) FENIX_RELEASE else FENIX_BETA, + releaseType = releaseType, + ) + } else { + fetchReleaseApksForVersion( + version = candidate.baseVersion, + displayVersion = version, + archiveUrl = archiveUrlForCandidate(candidate.baseVersion, candidate.buildNumber), + archiveAppName = FENIX, + resultAppName = if (releaseType == ReleaseType.Release) FENIX_RELEASE else FENIX_BETA, + cacheBuildKey = "candidate-${candidate.baseVersion}-build${candidate.buildNumber}", + ) + } } catch (e: Exception) { NetworkResult.Error("Failed to fetch Fenix release $version: ${e.message}", e) } @@ -196,7 +229,25 @@ class DefaultMozillaArchiveRepository( releaseType: ReleaseType, ): NetworkResult> { val releaseUrl = archiveUrlForRelease(archiveBaseUrl, version) - val releaseHtml = mozillaArchivesApiService.getHtmlPage(releaseUrl) + return fetchReleaseApksForVersion( + version = version, + displayVersion = version, + archiveUrl = releaseUrl, + archiveAppName = archiveAppName, + resultAppName = resultAppName, + cacheBuildKey = "", + ) + } + + private suspend fun fetchReleaseApksForVersion( + version: String, + displayVersion: String, + archiveUrl: String, + archiveAppName: String, + resultAppName: String, + cacheBuildKey: String, + ): NetworkResult> { + val releaseHtml = mozillaArchivesApiService.getHtmlPage(archiveUrl) val abis = mozillaArchiveHtmlParser.parseFenixReleaseAbisFromHtml(releaseHtml, archiveAppName) if (abis.isEmpty()) { @@ -204,7 +255,7 @@ class DefaultMozillaArchiveRepository( } val apks = abis.map { abi -> - constructReleaseApk(version, abi, releaseUrl, archiveAppName, resultAppName) + constructReleaseApk(version, displayVersion, abi, archiveUrl, archiveAppName, resultAppName, cacheBuildKey) } if (apks.isEmpty()) { @@ -216,10 +267,12 @@ class DefaultMozillaArchiveRepository( 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" @@ -227,15 +280,53 @@ class DefaultMozillaArchiveRepository( return MozillaArchiveApk( originalString = buildString, - rawDateString = "", // Release builds don't have date strings + rawDateString = cacheBuildKey, // Empty for releases; candidates need an isolated cache key. appName = resultAppName, - version = version, + version = displayVersion, abiName = abi, fullUrl = fullUrl, fileName = fileName, ) } + 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() + } + } + } + + private data class CandidateVersion(val baseVersion: String, val buildNumber: Int) + + private suspend fun getHtmlPageOrNull(url: String): String? = try { + mozillaArchivesApiService.getHtmlPage(url) + } catch (e: CancellationException) { + throw e + } catch (_: Exception) { + null + } + + private 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()) + } + private suspend fun getNightlyBuilds(appName: String, date: LocalDate? = null): NetworkResult> { if (date != null) { val url = archiveUrlForDate(appName, date) diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt index faabbfe..a08061c 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt @@ -1,18 +1,26 @@ package org.mozilla.tryfox.ui.composables -import androidx.compose.foundation.BorderStroke +import android.os.Build +import android.text.Editable +import android.text.InputType +import android.text.TextWatcher +import android.view.ViewGroup +import android.widget.EditText +import android.widget.NumberPicker import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.wrapContentWidth +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons @@ -29,21 +37,21 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ModalBottomSheet +import androidx.compose.material3.RadioButton import androidx.compose.material3.SegmentedButton import androidx.compose.material3.SegmentedButtonDefaults import androidx.compose.material3.SelectableDates import androidx.compose.material3.SingleChoiceSegmentedButtonRow -import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.material3.rememberDatePickerState +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -51,13 +59,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.selected import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView import kotlinx.datetime.Clock import kotlinx.datetime.Instant import kotlinx.datetime.LocalDate @@ -253,7 +264,7 @@ private fun ArchiveGroupHeader( ) // Push the version selector to the right edge of the row. Spacer(modifier = Modifier.weight(1f)) - ReleaseVersionSelector( + VersionSelector( appName = appName, selectedReleaseVersion = selectedReleaseVersion ?: version.takeIf { it.isNotEmpty() }, availableReleaseVersions = availableReleaseVersions, @@ -358,80 +369,249 @@ private fun ArchiveGroupHeader( } @Composable -private fun ReleaseVersionSelector( +internal fun VersionSelector( appName: String, selectedReleaseVersion: String?, availableReleaseVersions: List, onReleaseVersionSelected: (String) -> Unit, ) { - var expanded by remember { mutableStateOf(false) } + var showSelector by remember { mutableStateOf(false) } val selectedVersion = selectedReleaseVersion ?: availableReleaseVersions.firstOrNull() - Box { - Surface( - modifier = Modifier - .clickable(enabled = availableReleaseVersions.isNotEmpty()) { expanded = true } - .semantics { - contentDescription = "Selected Firefox Release version ${selectedVersion ?: ""}" - } - .testTag("release_version_chip_${appName.lowercase()}"), - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), - border = BorderStroke( - width = 1.dp, - color = MaterialTheme.colorScheme.outlineVariant, - ), - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), - ) { - Text( - text = selectedVersion ?: "--", - style = MaterialTheme.typography.labelLarge, - fontWeight = FontWeight.SemiBold, - ) - Icon( - imageVector = if (expanded) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, - contentDescription = stringResource(R.string.release_version_chip_description), - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable(enabled = availableReleaseVersions.isNotEmpty()) { showSelector = true } + .semantics { + contentDescription = "Selected Firefox Release version ${selectedVersion ?: ""}" } - } + .testTag("release_version_chip_${appName.lowercase()}") + .padding(horizontal = 12.dp, vertical = 6.dp), + ) { + Text( + text = selectedVersion ?: "--", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + ) + Icon( + imageVector = if (showSelector) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.release_version_chip_description), + ) + } + + if (showSelector) { + VersionSelectorSheet( + appName = appName, + selectedVersion = selectedVersion, + availableVersions = availableReleaseVersions, + onDismiss = { showSelector = false }, + onConfirm = { version -> + showSelector = false + onReleaseVersionSelected(version) + }, + ) + } +} + +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() - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - offset = DpOffset(x = 0.dp, y = 4.dp), +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VersionSelectorSheet( + appName: String, + selectedVersion: String?, + availableVersions: List, + onDismiss: () -> Unit, + onConfirm: (String) -> Unit, +) { + val versionsByMajor = remember(availableVersions) { + availableVersions.mapNotNull { version -> versionMajor(version)?.let { it to version } }.groupBy({ it.first }, { it.second }) + } + val initialMajor = versionMajor(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 isActiveMajorSelectable = activeMajor in MINIMUM_SUPPORTED_MAJOR_VERSION..maximumSupportedMajorVersion + val variants = if (isActiveMajorSelectable) versionsByMajor[activeMajor].orEmpty() else emptyList() + val title = if (appName == FENIX_BETA || appName == FOCUS_BETA) { + stringResource(R.string.version_selector_beta_title) + } else { + stringResource(R.string.version_selector_release_title) + } + val pickerTextColor = MaterialTheme.colorScheme.onSurface.toArgb() + + fun selectMajor(major: Int) { + activeMajor = major + draftVersion = draftVersion?.takeIf { versionMajor(it) == major } + } + + ModalBottomSheet( + onDismissRequest = onDismiss, + sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true), + modifier = Modifier.testTag("release_version_selector_sheet_${appName.lowercase()}"), + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .fillMaxHeight(0.85f) + .padding(horizontal = 24.dp, vertical = 8.dp), ) { - Column( + Text(text = title, style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text( + text = stringResource(R.string.version_selector_helper), + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), + ) + Row( modifier = Modifier - .heightIn(max = 280.dp) - .verticalScroll(rememberScrollState()), + .fillMaxWidth() + .weight(1f) + .padding(start = 16.dp, top = 4.dp), ) { - availableReleaseVersions.forEach { version -> - DropdownMenuItem( - text = { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.wrapContentWidth(), + ) { + Text( + text = stringResource(R.string.version_selector_version_label), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + ) + AndroidView( + factory = { context -> + NumberPicker(context).apply { + minValue = MINIMUM_SUPPORTED_MAJOR_VERSION + maxValue = maximumSupportedMajorVersion + value = activeMajor.coerceIn(minValue, maxValue) + wrapSelectorWheel = false + descendantFocusability = ViewGroup.FOCUS_AFTER_DESCENDANTS + setOnValueChangedListener { _, _, newValue -> selectMajor(newValue) } + post { + setSelectorTextColor(pickerTextColor) + editableInput()?.apply { + inputType = InputType.TYPE_CLASS_NUMBER + setSelectAllOnFocus(true) + addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(text: CharSequence?, start: Int, count: Int, after: Int) = Unit + + override fun onTextChanged(text: CharSequence?, start: Int, before: Int, count: Int) = Unit + + override fun afterTextChanged(text: Editable?) { + text?.toString()?.toIntOrNull()?.takeIf { + it in MINIMUM_SUPPORTED_MAJOR_VERSION..maximumSupportedMajorVersion + }?.let(::selectMajor) + } + }) + } + } + } + }, + update = { picker -> + if (picker.value != activeMajor) picker.value = activeMajor + }, + modifier = Modifier + .wrapContentWidth() + .height(VersionPickerWheelHeight) + .clipToBounds() + .testTag("release_version_major_picker_${appName.lowercase()}"), + ) + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .padding(start = 24.dp), + ) { + Text( + text = stringResource(R.string.version_selector_available_builds), + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.Bold, + modifier = Modifier.padding(bottom = 8.dp), + ) + if (variants.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth()) { Text( - text = version, - fontWeight = if (version == selectedVersion) { - FontWeight.SemiBold + text = if (isActiveMajorSelectable) { + stringResource(R.string.version_selector_no_builds) } else { - FontWeight.Normal + stringResource( + R.string.version_selector_current_major_unavailable, + activeMajor, + MINIMUM_SUPPORTED_MAJOR_VERSION, + maximumSupportedMajorVersion, + ) }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 16.dp), ) - }, - onClick = { - expanded = false - onReleaseVersionSelected(version) - }, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.dp), - ) + } + } else { + LazyColumn( + modifier = Modifier + .weight(1f, fill = false) + .wrapContentWidth(), + ) { + items(variants, key = { it }) { version -> + val selected = draftVersion == version + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .wrapContentWidth() + .clickable { + draftVersion = version + onConfirm(version) + } + .semantics { this.selected = selected } + .testTag("release_version_variant_${version.replace('.', '_')}") + .padding(vertical = 2.dp), + ) { + RadioButton( + selected = selected, + onClick = { + draftVersion = version + onConfirm(version) + }, + ) + Text( + text = version, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.padding(start = 12.dp), + ) + } + } + } + } } } + Row( + horizontalArrangement = Arrangement.End, + modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 16.dp), + ) { + TextButton(onClick = onDismiss) { Text(stringResource(id = android.R.string.cancel)) } + } } } } +private fun NumberPicker.editableInput(): EditText? { + val inputId = resources.getIdentifier("numberpicker_input", "id", "android") + return inputId.takeIf { it != 0 }?.let { findViewById(it) as? EditText } +} + +private fun NumberPicker.setSelectorTextColor(color: Int) { + editableInput()?.setTextColor(color) + if (Build.VERSION.SDK_INT >= 36) { + setTextColor(color) + } +} + @Suppress("KotlinConstantConditions") @OptIn(ExperimentalMaterial3Api::class) @Composable 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 91d04be..f2ffcc2 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 @@ -5,7 +5,6 @@ import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.Row @@ -16,7 +15,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.ArrowDropDown import androidx.compose.material.icons.filled.CalendarToday import androidx.compose.material3.AlertDialog import androidx.compose.material3.AssistChip @@ -25,8 +23,6 @@ import androidx.compose.material3.CardDefaults import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.DatePicker import androidx.compose.material3.DatePickerDialog -import androidx.compose.material3.DropdownMenu -import androidx.compose.material3.DropdownMenuItem import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.FilterChip import androidx.compose.material3.Icon @@ -65,6 +61,7 @@ import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.ui.composables.AppIcon import org.mozilla.tryfox.ui.composables.CurrentInstallState import org.mozilla.tryfox.ui.composables.DownloadButton +import org.mozilla.tryfox.ui.composables.VersionSelector import org.mozilla.tryfox.ui.composables.rememberLinkedPushComment import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult @@ -317,20 +314,12 @@ internal fun datePickerSelectionDate(selectionMillis: Long): LocalDate = @Composable private fun ReleaseVersionDetails(appName: String, selectedVersion: String, versions: List, onSelected: (String, String) -> Unit) { - var expanded by remember { mutableStateOf(false) } - Box { - Row( - modifier = Modifier.clickable(enabled = versions.isNotEmpty()) { expanded = true } - .testTag("home_release_version_$appName").padding(vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically, - ) { - Text(selectedVersion, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Icon(Icons.Default.ArrowDropDown, contentDescription = "Select version") - } - DropdownMenu(expanded = expanded, onDismissRequest = { expanded = false }) { - versions.forEach { version -> DropdownMenuItem(text = { Text(version) }, onClick = { expanded = false; onSelected(appName, version) }) } - } - } + VersionSelector( + appName = appName, + selectedReleaseVersion = selectedVersion, + availableReleaseVersions = versions, + onReleaseVersionSelected = { version -> onSelected(appName, version) }, + ) } private fun AppUiModel.toAppState(): AppState? = installedVersion?.let { 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 b343523..fb9d449 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 @@ -454,8 +454,8 @@ class HomeViewModel( private fun convertParsedApksToUiModels(parsedApks: List): List { return parsedApks.map { parsedApk -> - val date = parsedApk.rawDateString?.formatNightlyBuildDate() val buildDate = parsedApk.rawDateString?.rawNightlyBuildDate() + val date = buildDate?.let { parsedApk.rawDateString?.formatNightlyBuildDate().orEmpty() }.orEmpty() val isCompatible = supportedAbis.any { deviceAbi -> deviceAbi.equals( parsedApk.abiName, diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index caff014..f04d42d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,6 +109,16 @@ Unsupported ABI Clear date selection Select Firefox Release version + Choose Release version + Choose Beta version + Select or type a version, then choose the exact build. + Version + Major version + Previous major version + Next major version + Builds: + No builds are available for this major version. + The current major version (%1$d) is outside the selectable range. Choose a version from %2$d to %3$d. Select build Uninstall Installed diff --git a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt index aa2ad9c..d4a550e 100644 --- a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt @@ -443,6 +443,101 @@ class FenixReleaseTest { assertTrue(result < 0, "145.0.1 should be less than 145.0b5 based on numeric parts") } + @Test + fun `candidate parser filters channel and orders build numbers`() { + val candidatesHtml = loadHtmlResource("fenix-candidates-page.html") + val buildsHtml = loadHtmlResource("fenix-candidate-builds.html") + + assertEquals(listOf("153.0.5", "153.0.4"), parser.parseFenixCandidateVersionsFromHtml(candidatesHtml, ReleaseType.Release)) + assertEquals(listOf("153.0b5", "153.0b4"), parser.parseFenixCandidateVersionsFromHtml(candidatesHtml, ReleaseType.Beta)) + assertEquals(listOf(2, 1), parser.parseCandidateBuildNumbersFromHtml(buildsHtml)) + assertTrue(parser.compareReleaseVersions("153.0.5-RC2", "153.0.5-RC1") > 0) + } + + @Test + fun `Fenix release versions include candidates for published and unpublished bases`() = runBlocking { + val api: MozillaArchivesApiService = mock() + val releasesHtml = """ + 153.0.4/ + 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"), + (repository.getFenixReleaseVersions(ReleaseType.Release) as NetworkResult.Success).data, + ) + assertEquals( + listOf("153.0b5-RC2", "153.0b5-RC1", "153.0b4", "153.0b4-RC2", "153.0b4-RC1"), + (repository.getFenixReleaseVersions(ReleaseType.Beta) as NetworkResult.Success).data, + ) + } + + @Test + fun `Fenix release versions merge published and candidate builds in semantic order`() = 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) + 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" }) + } + // Helper method private fun loadHtmlResource(resourceName: String): String { 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 809691a..80a8472 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 @@ -734,6 +734,41 @@ class HomeViewModelTest { assertEquals("144.0.2", (fenixReleaseApp.apks as ApksResult.Success).apks.first().version) } + @Test + fun `onReleaseVersionSelected uses candidate build cache identity`() = runTest { + val stableApk = createTestParsedReleaseApk(version = "153.0.4") + val candidateApk = createTestParsedReleaseApk(version = "153.0.4") + .copy(version = "153.0.4-RC2", rawDateString = "candidate-153.0.4-build2") + val releaseRepositories = listOf( + FenixReleaseReleaseRepository( + FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("153.0.4", "153.0.4-RC2")), + fenixReleasesByVersion = mapOf( + "153.0.4" to NetworkResult.Success(listOf(stableApk)), + "153.0.4-RC2" to NetworkResult.Success(listOf(candidateApk)), + ), + ), + ), + ) + viewModel = createViewModel(releaseRepositories = releaseRepositories) + fakeCacheManager.setCacheState(CacheManagementState.IdleEmpty) + + viewModel.initialLoad() + advanceUntilIdle() + val stableApp = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps[FENIX_RELEASE]!! + val stableKey = (stableApp.apks as ApksResult.Success).apks.single().uniqueKey + + viewModel.onReleaseVersionSelected(FENIX_RELEASE, "153.0.4-RC2") + advanceUntilIdle() + + val candidateApp = (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps[FENIX_RELEASE]!! + val candidate = (candidateApp.apks as ApksResult.Success).apks.single() + assertEquals("153.0.4-RC2", candidateApp.selectedReleaseVersion) + assertEquals("153.0.4-RC2", candidate.version) + assertEquals("fenix-release/candidate-153.0.4-build2/${candidate.fileName}", candidate.uniqueKey) + assertTrue(candidate.uniqueKey != stableKey) + } + @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/resources/fenix-candidate-android.html b/app/src/test/resources/fenix-candidate-android.html new file mode 100644 index 0000000..3e2da68 --- /dev/null +++ b/app/src/test/resources/fenix-candidate-android.html @@ -0,0 +1,4 @@ + + + +
Dirfenix-153.0.4-android-arm64-v8a/
Dirfenix-153.0.4-android/
diff --git a/app/src/test/resources/fenix-candidate-builds.html b/app/src/test/resources/fenix-candidate-builds.html new file mode 100644 index 0000000..2f962f5 --- /dev/null +++ b/app/src/test/resources/fenix-candidate-builds.html @@ -0,0 +1,5 @@ + + + + +
Dirbuild1/
Dirbuild2/
Dirbuildx/
diff --git a/app/src/test/resources/fenix-candidates-page.html b/app/src/test/resources/fenix-candidates-page.html new file mode 100644 index 0000000..70e9314 --- /dev/null +++ b/app/src/test/resources/fenix-candidates-page.html @@ -0,0 +1,7 @@ + + + + + + +
Dir153.0.4-candidates/
Dir153.0.5-candidates/
Dir153.0b4-candidates/
Dir153.0b5-candidates/
Dirnot-a-version-candidates/