From f76be70326ccdc8a59c09d7fd96c8bac972dc245 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 11 Aug 2026 16:05:33 +0200 Subject: [PATCH 1/3] Add mobile release version selector --- .../ArchiveGroupCardHeaderLayoutTest.kt | 89 ++++++ .../tryfox/ui/screens/HomeAppCardTest.kt | 57 ++++ .../tryfox/ui/composables/ArchiveGroupCard.kt | 257 ++++++++++++++---- .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 25 +- app/src/main/res/values/strings.xml | 12 + 5 files changed, 365 insertions(+), 75 deletions(-) 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..bd89044 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,13 @@ package org.mozilla.tryfox.ui.composables import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.assertIsNotEnabled +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 @@ -15,6 +19,7 @@ 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.FOCUS +import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_RELEASE import java.io.File @@ -113,6 +118,90 @@ 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.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() + composeTestRule.onNodeWithText("Select version").performClick() + + assertTrue(confirmedVersion == "151.0.0") + } + + @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_keepsOutOfRangeCurrentMajorUnavailableUntilAnotherMajorIsChosen() { + 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.onNodeWithText("The current major version (155) is outside the selectable range. Choose a version from 117 to 154.") + .assertIsDisplayed() + composeTestRule.onNodeWithText("Select version").assertIsNotEnabled() + } + 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/ui/composables/ArchiveGroupCard.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/ArchiveGroupCard.kt index faabbfe..a1bdd3f 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,23 +1,29 @@ 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 -import androidx.compose.material.icons.filled.ArrowDropDown -import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog @@ -29,13 +35,13 @@ 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 @@ -44,6 +50,7 @@ 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 +58,15 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +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 +262,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 +367,214 @@ 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( + Surface( + modifier = Modifier + .clickable(enabled = availableReleaseVersions.isNotEmpty()) { showSelector = true } + .semantics { + contentDescription = "Selected version ${selectedVersion ?: ""}" + } + .testTag("release_version_chip_${appName.lowercase()}"), + shape = MaterialTheme.shapes.medium, + color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), + ) { + Text( + text = selectedVersion ?: "--", + style = MaterialTheme.typography.labelLarge, + fontWeight = FontWeight.SemiBold, + modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + } + + 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 const val MAXIMUM_SUPPORTED_MAJOR_VERSION = 154 + +private fun versionMajor(version: String): Int? = + Regex("^(\\d+)(?:\\.|$)").find(version)?.groupValues?.getOrNull(1)?.toIntOrNull() + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun VersionSelectorSheet( + appName: String, + selectedVersion: String?, + availableVersions: List, + 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 + var activeMajor by remember(selectedVersion, availableVersions) { mutableStateOf(initialMajor) } + var draftVersion by remember(selectedVersion, availableVersions) { mutableStateOf(selectedVersion) } + val isActiveMajorSelectable = activeMajor in MINIMUM_SUPPORTED_MAJOR_VERSION..MAXIMUM_SUPPORTED_MAJOR_VERSION + 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 - .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, - ), + .fillMaxWidth() + .fillMaxHeight(0.85f) + .padding(horizontal = 24.dp, vertical = 8.dp), ) { + 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( verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 6.dp), + modifier = Modifier.fillMaxWidth().padding(top = 4.dp), ) { Text( - text = selectedVersion ?: "--", + text = stringResource(R.string.version_selector_major_label), 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), + AndroidView( + factory = { context -> + NumberPicker(context).apply { + minValue = MINIMUM_SUPPORTED_MAJOR_VERSION + maxValue = MAXIMUM_SUPPORTED_MAJOR_VERSION + 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..MAXIMUM_SUPPORTED_MAJOR_VERSION + }?.let(::selectMajor) + } + }) + } + } + } + }, + update = { picker -> + if (picker.value != activeMajor) picker.value = activeMajor + }, + modifier = Modifier + .wrapContentWidth() + .padding(start = 16.dp) + .testTag("release_version_major_picker_${appName.lowercase()}"), ) } - } - - DropdownMenu( - expanded = expanded, - onDismissRequest = { expanded = false }, - offset = DpOffset(x = 0.dp, y = 4.dp), - ) { - Column( - modifier = Modifier - .heightIn(max = 280.dp) - .verticalScroll(rememberScrollState()), - ) { - availableReleaseVersions.forEach { version -> - DropdownMenuItem( - text = { + Text( + text = stringResource(R.string.version_selector_available_builds), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier.padding(top = 20.dp, bottom = 8.dp), + ) + if (variants.isEmpty()) { + Box(modifier = Modifier.fillMaxWidth().weight(1f)) { + Text( + text = if (isActiveMajorSelectable) { + stringResource(R.string.version_selector_no_builds) + } else { + stringResource(R.string.version_selector_current_major_unavailable, activeMajor) + }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 16.dp), + ) + } + } else { + LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) { + items(variants, key = { it }) { version -> + val selected = draftVersion == version + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { draftVersion = version } + .semantics { this.selected = selected } + .testTag("release_version_variant_${version.replace('.', '_')}") + .padding(vertical = 10.dp), + ) { + RadioButton(selected = selected, onClick = { draftVersion = version }) Text( text = version, - fontWeight = if (version == selectedVersion) { - FontWeight.SemiBold - } else { - FontWeight.Normal - }, + style = MaterialTheme.typography.bodyLarge, + fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, + modifier = Modifier.padding(start = 12.dp), ) - }, - onClick = { - expanded = false - onReleaseVersionSelected(version) - }, - contentPadding = PaddingValues(horizontal = 16.dp, vertical = 4.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)) } + Button( + onClick = { draftVersion?.let(onConfirm) }, + enabled = isActiveMajorSelectable && draftVersion != null && variants.contains(draftVersion), + ) { Text(stringResource(R.string.version_selector_confirm)) } + } } } } +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/res/values/strings.xml b/app/src/main/res/values/strings.xml index caff014..6ffee15 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -109,6 +109,18 @@ Unsupported ABI Clear date selection Select Firefox Release version + Choose Release version + Choose Beta version + Select or type a major version, then choose the exact build. + Major version + Major version + Previous major version + Next major version + Enter a major version between 117 and 154. + Available builds + No builds are available for this major version. + The current major version (%1$d) is outside the selectable range. Choose a version from 117 to 154. + Select version Select build Uninstall Installed From f64e5f3f2e5e8a654b028dda4021d269bc310838 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 11 Aug 2026 16:31:50 +0200 Subject: [PATCH 2/3] Add Fenix release candidate versions --- .../ArchiveGroupCardHeaderLayoutTest.kt | 47 +++++++ .../tryfox/data/MozillaArchiveHtmlParser.kt | 47 +++++++ .../DefaultMozillaArchiveRepository.kt | 119 ++++++++++++++++-- .../tryfox/ui/screens/HomeViewModel.kt | 2 +- .../org/mozilla/tryfox/FenixReleaseTest.kt | 69 ++++++++++ .../tryfox/ui/screens/HomeViewModelTest.kt | 35 ++++++ .../resources/fenix-candidate-android.html | 4 + .../resources/fenix-candidate-builds.html | 5 + .../test/resources/fenix-candidates-page.html | 7 ++ 9 files changed, 321 insertions(+), 14 deletions(-) create mode 100644 app/src/test/resources/fenix-candidate-android.html create mode 100644 app/src/test/resources/fenix-candidate-builds.html create mode 100644 app/src/test/resources/fenix-candidates-page.html 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 bd89044..510d414 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 @@ -18,6 +18,8 @@ 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 @@ -202,6 +204,51 @@ class ArchiveGroupCardHeaderLayoutTest { composeTestRule.onNodeWithText("Select version").assertIsNotEnabled() } + @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() + composeTestRule.onNodeWithText("Select version").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/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..fa0c78e 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, releaseVersions.toSet()) + 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,55 @@ 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, + publishedVersions: Set, + ): List { + val candidatesHtml = getHtmlPageOrNull(CANDIDATES_FENIX_BASE_URL) ?: return emptyList() + val candidateBases = mozillaArchiveHtmlParser + .parseFenixCandidateVersionsFromHtml(candidatesHtml, releaseType) + .filterNot(publishedVersions::contains) + + 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/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/test/java/org/mozilla/tryfox/FenixReleaseTest.kt b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt index aa2ad9c..2dfbb35 100644 --- a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt @@ -443,6 +443,75 @@ 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 unpublished candidates and suppress published 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.5"))).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"), + (repository.getFenixReleaseVersions(ReleaseType.Release) as NetworkResult.Success).data, + ) + assertEquals( + listOf("153.0b5-RC2", "153.0b5-RC1", "153.0b4"), + (repository.getFenixReleaseVersions(ReleaseType.Beta) 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/
From 013358b8f469bed16f47aba87e2ef9c3d467b495 Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 11 Aug 2026 17:54:53 +0200 Subject: [PATCH 3/3] Improve Fenix release version picker --- .../ArchiveGroupCardHeaderLayoutTest.kt | 17 +- .../DefaultMozillaArchiveRepository.kt | 4 +- .../tryfox/ui/composables/ArchiveGroupCard.kt | 211 ++++++++++-------- app/src/main/res/values/strings.xml | 10 +- .../org/mozilla/tryfox/FenixReleaseTest.kt | 32 ++- 5 files changed, 168 insertions(+), 106 deletions(-) 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 510d414..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,7 +1,6 @@ package org.mozilla.tryfox.ui.composables import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.assertIsNotEnabled import androidx.compose.ui.test.assertIsSelected import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onAllNodesWithTag @@ -140,13 +139,20 @@ class ArchiveGroupCardHeaderLayoutTest { 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() - composeTestRule.onNodeWithText("Select version").performClick() assertTrue(confirmedVersion == "151.0.0") + assertTrue( + composeTestRule + .onAllNodesWithTag("release_version_selector_sheet_focus-release", useUnmergedTree = true) + .fetchSemanticsNodes() + .isEmpty(), + ) } @Test @@ -183,7 +189,7 @@ class ArchiveGroupCardHeaderLayoutTest { } @Test - fun versionSelector_keepsOutOfRangeCurrentMajorUnavailableUntilAnotherMajorIsChosen() { + fun versionSelector_supportsTheHighestMajorProvidedByTheArchive() { composeTestRule.setContent { TryFoxTheme(dynamicColor = false) { ArchiveGroupCard( @@ -199,9 +205,7 @@ class ArchiveGroupCardHeaderLayoutTest { } composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick() - composeTestRule.onNodeWithText("The current major version (155) is outside the selectable range. Choose a version from 117 to 154.") - .assertIsDisplayed() - composeTestRule.onNodeWithText("Select version").assertIsNotEnabled() + composeTestRule.onNodeWithTag("release_version_variant_155_0_1", useUnmergedTree = true).assertIsDisplayed() } @Test @@ -224,7 +228,6 @@ class ArchiveGroupCardHeaderLayoutTest { composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick() composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).performClick() - composeTestRule.onNodeWithText("Select version").performClick() assertTrue(confirmedVersion == "153.0.4-RC2") } 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 fa0c78e..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 @@ -121,7 +121,7 @@ class DefaultMozillaArchiveRepository( return try { val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FENIX_BASE_URL) val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, releaseType) - val candidateVersions = fetchFenixCandidateVersions(releaseType, releaseVersions.toSet()) + val candidateVersions = fetchFenixCandidateVersions(releaseType) val versions = (releaseVersions + candidateVersions) .distinct() .sortedWith(mozillaArchiveHtmlParser::compareReleaseVersions) @@ -291,12 +291,10 @@ class DefaultMozillaArchiveRepository( private suspend fun fetchFenixCandidateVersions( releaseType: ReleaseType, - publishedVersions: Set, ): List { val candidatesHtml = getHtmlPageOrNull(CANDIDATES_FENIX_BASE_URL) ?: return emptyList() val candidateBases = mozillaArchiveHtmlParser .parseFenixCandidateVersionsFromHtml(candidatesHtml, releaseType) - .filterNot(publishedVersions::contains) return coroutineScope { candidateBases 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 a1bdd3f..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 @@ -24,6 +24,8 @@ import androidx.compose.foundation.lazy.items import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.ArrowDropDown +import androidx.compose.material.icons.filled.ArrowDropUp import androidx.compose.material.icons.filled.Clear import androidx.compose.material.icons.filled.Warning import androidx.compose.material3.AlertDialog @@ -46,7 +48,6 @@ 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 @@ -58,6 +59,7 @@ 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 @@ -376,21 +378,24 @@ internal fun VersionSelector( var showSelector by remember { mutableStateOf(false) } val selectedVersion = selectedReleaseVersion ?: availableReleaseVersions.firstOrNull() - Surface( + Row( + verticalAlignment = Alignment.CenterVertically, modifier = Modifier .clickable(enabled = availableReleaseVersions.isNotEmpty()) { showSelector = true } .semantics { - contentDescription = "Selected version ${selectedVersion ?: ""}" + contentDescription = "Selected Firefox Release version ${selectedVersion ?: ""}" } - .testTag("release_version_chip_${appName.lowercase()}"), - shape = MaterialTheme.shapes.medium, - color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.95f), + .testTag("release_version_chip_${appName.lowercase()}") + .padding(horizontal = 12.dp, vertical = 6.dp), ) { Text( text = selectedVersion ?: "--", style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.SemiBold, - modifier = Modifier.padding(horizontal = 12.dp, vertical = 8.dp), + ) + Icon( + imageVector = if (showSelector) Icons.Default.ArrowDropUp else Icons.Default.ArrowDropDown, + contentDescription = stringResource(R.string.release_version_chip_description), ) } @@ -409,7 +414,7 @@ internal fun VersionSelector( } private const val MINIMUM_SUPPORTED_MAJOR_VERSION = 117 -private const val MAXIMUM_SUPPORTED_MAJOR_VERSION = 154 +private val VersionPickerWheelHeight = 156.dp private fun versionMajor(version: String): Int? = Regex("^(\\d+)(?:\\.|$)").find(version)?.groupValues?.getOrNull(1)?.toIntOrNull() @@ -429,9 +434,10 @@ private fun VersionSelectorSheet( 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..MAXIMUM_SUPPORTED_MAJOR_VERSION + 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) @@ -463,89 +469,124 @@ private fun VersionSelectorSheet( modifier = Modifier.padding(top = 4.dp, bottom = 16.dp), ) Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxWidth().padding(top = 4.dp), + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .padding(start = 16.dp, top = 4.dp), ) { - Text( - text = stringResource(R.string.version_selector_major_label), - style = MaterialTheme.typography.labelLarge, - ) - AndroidView( - factory = { context -> - NumberPicker(context).apply { - minValue = MINIMUM_SUPPORTED_MAJOR_VERSION - maxValue = MAXIMUM_SUPPORTED_MAJOR_VERSION - 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..MAXIMUM_SUPPORTED_MAJOR_VERSION - }?.let(::selectMajor) - } - }) + 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() - .padding(start = 16.dp) - .testTag("release_version_major_picker_${appName.lowercase()}"), - ) - } - Text( - text = stringResource(R.string.version_selector_available_builds), - style = MaterialTheme.typography.labelLarge, - modifier = Modifier.padding(top = 20.dp, bottom = 8.dp), - ) - if (variants.isEmpty()) { - Box(modifier = Modifier.fillMaxWidth().weight(1f)) { - Text( - text = if (isActiveMajorSelectable) { - stringResource(R.string.version_selector_no_builds) - } else { - stringResource(R.string.version_selector_current_major_unavailable, activeMajor) }, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier.padding(vertical = 16.dp), + update = { picker -> + if (picker.value != activeMajor) picker.value = activeMajor + }, + modifier = Modifier + .wrapContentWidth() + .height(VersionPickerWheelHeight) + .clipToBounds() + .testTag("release_version_major_picker_${appName.lowercase()}"), ) } - } else { - LazyColumn(modifier = Modifier.fillMaxWidth().weight(1f)) { - items(variants, key = { it }) { version -> - val selected = draftVersion == version - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { draftVersion = version } - .semantics { this.selected = selected } - .testTag("release_version_variant_${version.replace('.', '_')}") - .padding(vertical = 10.dp), - ) { - RadioButton(selected = selected, onClick = { draftVersion = version }) + 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, - style = MaterialTheme.typography.bodyLarge, - fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal, - modifier = Modifier.padding(start = 12.dp), + text = if (isActiveMajorSelectable) { + stringResource(R.string.version_selector_no_builds) + } else { + stringResource( + R.string.version_selector_current_major_unavailable, + activeMajor, + MINIMUM_SUPPORTED_MAJOR_VERSION, + maximumSupportedMajorVersion, + ) + }, + style = MaterialTheme.typography.bodyMedium, + modifier = Modifier.padding(vertical = 16.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), + ) + } + } + } } } } @@ -554,10 +595,6 @@ private fun VersionSelectorSheet( modifier = Modifier.fillMaxWidth().padding(top = 16.dp, bottom = 16.dp), ) { TextButton(onClick = onDismiss) { Text(stringResource(id = android.R.string.cancel)) } - Button( - onClick = { draftVersion?.let(onConfirm) }, - enabled = isActiveMajorSelectable && draftVersion != null && variants.contains(draftVersion), - ) { Text(stringResource(R.string.version_selector_confirm)) } } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 6ffee15..f04d42d 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -111,16 +111,14 @@ Select Firefox Release version Choose Release version Choose Beta version - Select or type a major version, then choose the exact build. - Major version + Select or type a version, then choose the exact build. + Version Major version Previous major version Next major version - Enter a major version between 117 and 154. - Available builds + Builds: No builds are available for this major version. - The current major version (%1$d) is outside the selectable range. Choose a version from 117 to 154. - Select 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 2dfbb35..d4a550e 100644 --- a/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/FenixReleaseTest.kt @@ -455,7 +455,7 @@ class FenixReleaseTest { } @Test - fun `Fenix release versions include unpublished candidates and suppress published bases`() = runBlocking { + fun `Fenix release versions include candidates for published and unpublished bases`() = runBlocking { val api: MozillaArchivesApiService = mock() val releasesHtml = """ 153.0.4/ @@ -465,21 +465,47 @@ class FenixReleaseTest { 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"), + 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"), + 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()