Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package org.mozilla.tryfox.ui.composables

import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.assertIsOff
import androidx.compose.ui.test.assertIsOn
import androidx.compose.ui.test.assertIsSelected
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onAllNodesWithTag
Expand Down Expand Up @@ -117,6 +119,7 @@ class VersionSelectorTest {
}

composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick()
composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-release", useUnmergedTree = true).performClick()
composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).performClick()

assertTrue(confirmedVersion == "153.0.4-RC2")
Expand All @@ -136,6 +139,89 @@ class VersionSelectorTest {
}

composeTestRule.onNodeWithTag("release_version_chip_fenix-beta", useUnmergedTree = true).performClick()
composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-beta", useUnmergedTree = true).performClick()
composeTestRule.onNodeWithTag("release_version_variant_153_0b5-RC2", useUnmergedTree = true).assertIsDisplayed()
}

@Test
fun releaseCandidates_areHiddenUntilShown() {
composeTestRule.setContent {
TryFoxTheme(dynamicColor = false) {
VersionSelector(
appName = FENIX_RELEASE,
selectedReleaseVersion = "153.0.4",
availableReleaseVersions = listOf("153.0.4", "153.0.4-RC2", "153.0.4-RC1"),
onReleaseVersionSelected = {},
)
}
}

composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick()
composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-release", useUnmergedTree = true).assertIsOff()
composeTestRule.onNodeWithTag("release_version_variant_153_0_4", useUnmergedTree = true).assertIsDisplayed()
assertTrue(
composeTestRule
.onAllNodesWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true)
.fetchSemanticsNodes()
.isEmpty(),
)
}

@Test
fun releaseCandidates_areShownWhenAnRcIsSelected() {
composeTestRule.setContent {
TryFoxTheme(dynamicColor = false) {
VersionSelector(
appName = FENIX_RELEASE,
selectedReleaseVersion = "153.0.4-RC2",
availableReleaseVersions = listOf("153.0.4", "153.0.4-RC2", "153.0.4-RC1"),
onReleaseVersionSelected = {},
)
}
}

composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick()
composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-release", useUnmergedTree = true).assertIsOn()
composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).assertIsSelected()
}

@Test
fun releaseCandidates_areShownWhenOnlyRcBuildsExist() {
composeTestRule.setContent {
TryFoxTheme(dynamicColor = false) {
VersionSelector(
appName = FENIX_RELEASE,
selectedReleaseVersion = null,
availableReleaseVersions = listOf("153.0.4-RC2", "153.0.4-RC1"),
onReleaseVersionSelected = {},
)
}
}

composeTestRule.onNodeWithTag("release_version_chip_fenix-release", useUnmergedTree = true).performClick()
composeTestRule.onNodeWithTag("release_version_show_candidates_fenix-release", useUnmergedTree = true).assertIsOn()
composeTestRule.onNodeWithTag("release_version_variant_153_0_4-RC2", useUnmergedTree = true).assertIsDisplayed()
}

@Test
fun focusSelector_hasNoReleaseCandidateCheckbox() {
composeTestRule.setContent {
TryFoxTheme(dynamicColor = false) {
VersionSelector(
appName = FOCUS_RELEASE,
selectedReleaseVersion = "155.0.1",
availableReleaseVersions = listOf("155.0.1", "154.0.2"),
onReleaseVersionSelected = {},
)
}
}

composeTestRule.onNodeWithTag("release_version_chip_focus-release", useUnmergedTree = true).performClick()
assertTrue(
composeTestRule
.onAllNodesWithTag("release_version_show_candidates_focus-release", useUnmergedTree = true)
.fetchSemanticsNodes()
.isEmpty(),
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ class HomeAppCardTest {
onDateSelected = { _, _ -> },
dateValidator = { true },
onReleaseVersionSelected = { _, _ -> },
onReleaseMajorBrowsed = { _, _ -> },
onBuildSelected = { _, _ -> },
onDismissBuildPicker = {},
modifier = Modifier.width(350.dp),
Expand Down Expand Up @@ -139,6 +140,7 @@ class HomeAppCardTest {
onDateSelected = { _, _ -> },
dateValidator = { true },
onReleaseVersionSelected = { _, _ -> },
onReleaseMajorBrowsed = { _, _ -> },
onBuildSelected = { _, _ -> },
onDismissBuildPicker = {},
)
Expand Down Expand Up @@ -198,6 +200,7 @@ class HomeAppCardTest {
onDateSelected = { _, date -> selectedDate = date },
dateValidator = { true },
onReleaseVersionSelected = { _, _ -> },
onReleaseMajorBrowsed = { _, _ -> },
onBuildSelected = { _, _ -> },
onDismissBuildPicker = {},
)
Expand Down Expand Up @@ -255,6 +258,7 @@ class HomeAppCardTest {
onDateSelected = { _, _ -> },
dateValidator = { true },
onReleaseVersionSelected = { _, _ -> },
onReleaseMajorBrowsed = { _, _ -> },
onBuildSelected = { _, _ -> },
onDismissBuildPicker = {},
)
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
package org.mozilla.tryfox.data.repositories

import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.datetime.Clock
import kotlinx.datetime.DateTimeUnit
import kotlinx.datetime.Instant
import kotlinx.datetime.LocalDate
import kotlinx.datetime.TimeZone
import kotlinx.datetime.minus
Expand All @@ -23,15 +23,20 @@ import org.mozilla.tryfox.util.FOCUS_BETA
import org.mozilla.tryfox.util.FOCUS_RELEASE
import retrofit2.HttpException
import java.net.HttpURLConnection
import java.util.concurrent.ConcurrentHashMap
import kotlin.time.Duration.Companion.seconds

class DefaultMozillaArchiveRepository(
private val mozillaArchivesApiService: MozillaArchivesApiService,
private val clock: Clock = Clock.System,
private val mozillaArchiveHtmlParser: MozillaArchiveHtmlParser = MozillaArchiveHtmlParser(),
) : MozillaArchiveRepository {

private val listingLocks = ConcurrentHashMap<String, Mutex>()
private val listingCache = ConcurrentHashMap<String, CachedListing>()

companion object {
private const val CANDIDATE_BUILD_INDEX_CONCURRENCY = 4
private val LISTING_CACHE_TTL = 10.seconds
const val ARCHIVE_MOZILLA_BASE_URL = "https://archive.mozilla.org/pub/"
const val RELEASES_FENIX_BASE_URL = "${ARCHIVE_MOZILLA_BASE_URL}fenix/releases/"
const val CANDIDATES_FENIX_BASE_URL = "${ARCHIVE_MOZILLA_BASE_URL}fenix/candidates/"
Expand Down Expand Up @@ -67,8 +72,10 @@ class DefaultMozillaArchiveRepository(

override suspend fun getFenixReleaseBuilds(releaseType: ReleaseType): NetworkResult<List<MozillaArchiveApk>> {
return try {
val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FENIX_BASE_URL)
val latestReleaseVersion = mozillaArchiveHtmlParser.parseFenixReleasesFromHtml(releasesHtml, releaseType)
val latestReleaseVersion = mozillaArchiveHtmlParser
.releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FENIX_BASE_URL), releaseType)
.firstOrNull()
.orEmpty()

if (latestReleaseVersion.isEmpty()) {
return NetworkResult.Error("No releases found for type $releaseType", null)
Expand Down Expand Up @@ -96,11 +103,10 @@ class DefaultMozillaArchiveRepository(

private suspend fun getFocusReleaseBuilds(releaseType: ReleaseType): NetworkResult<List<MozillaArchiveApk>> {
return try {
val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FOCUS_BASE_URL)
val latestReleaseVersion = mozillaArchiveHtmlParser.parseFenixReleasesFromHtml(
releasesHtml,
releaseType,
)
val latestReleaseVersion = mozillaArchiveHtmlParser
.releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FOCUS_BASE_URL), releaseType)
.firstOrNull()
.orEmpty()

if (latestReleaseVersion.isEmpty()) {
return NetworkResult.Error("No releases found for Focus", null)
Expand All @@ -120,13 +126,8 @@ class DefaultMozillaArchiveRepository(

override suspend fun getFenixReleaseVersions(releaseType: ReleaseType): NetworkResult<List<String>> {
return try {
val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FENIX_BASE_URL)
val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, releaseType)
val candidateVersions = fetchFenixCandidateVersions(releaseType)
val versions = (releaseVersions + candidateVersions)
.distinct()
.sortedWith(mozillaArchiveHtmlParser::compareReleaseVersions)
.reversed()
val versions = mozillaArchiveHtmlParser
.releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FENIX_BASE_URL), releaseType)

if (versions.isEmpty()) {
return NetworkResult.Error("No release versions found for type $releaseType", null)
Expand All @@ -140,6 +141,33 @@ class DefaultMozillaArchiveRepository(
}
}

override suspend fun getFenixCandidateBaseVersions(releaseType: ReleaseType): NetworkResult<List<String>> {
return try {
NetworkResult.Success(
mozillaArchiveHtmlParser
.candidateBaseVersionsFromDirectories(getDirectoryListing(CANDIDATES_FENIX_BASE_URL), releaseType),
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
NetworkResult.Error("Failed to fetch Fenix candidates: ${e.message}", e)
}
}

override suspend fun getFenixCandidateVersions(baseVersion: String): NetworkResult<List<String>> {
return try {
val buildsHtml = mozillaArchivesApiService.getHtmlPage(archiveUrlForCandidateBuilds(baseVersion))
NetworkResult.Success(
mozillaArchiveHtmlParser.parseCandidateBuildNumbersFromHtml(buildsHtml)
.map { buildNumber -> "$baseVersion-RC$buildNumber" },
)
} catch (e: CancellationException) {
throw e
} catch (e: Exception) {
NetworkResult.Error("Failed to fetch Fenix $baseVersion candidates: ${e.message}", e)
}
}

override suspend fun getFocusReleaseVersions(): NetworkResult<List<String>> {
return getFocusReleaseVersions(ReleaseType.Release)
}
Expand All @@ -150,8 +178,8 @@ class DefaultMozillaArchiveRepository(

private suspend fun getFocusReleaseVersions(releaseType: ReleaseType): NetworkResult<List<String>> {
return try {
val releasesHtml = mozillaArchivesApiService.getHtmlPage(RELEASES_FOCUS_BASE_URL)
val releaseVersions = mozillaArchiveHtmlParser.parseFenixReleaseVersionsFromHtml(releasesHtml, releaseType)
val releaseVersions = mozillaArchiveHtmlParser
.releaseVersionsFromDirectories(getDirectoryListing(RELEASES_FOCUS_BASE_URL), releaseType)

if (releaseVersions.isEmpty()) {
return NetworkResult.Error("No Focus release versions found", null)
Expand Down Expand Up @@ -261,29 +289,6 @@ class DefaultMozillaArchiveRepository(
return NetworkResult.Success(apks)
}

private suspend fun fetchFenixCandidateVersions(
releaseType: ReleaseType,
): List<String> {
val candidatesHtml = getHtmlPageOrNull(CANDIDATES_FENIX_BASE_URL) ?: return emptyList()
val candidateBases = mozillaArchiveHtmlParser
.parseFenixCandidateVersionsFromHtml(candidatesHtml, releaseType)

return coroutineScope {
candidateBases
.chunked(CANDIDATE_BUILD_INDEX_CONCURRENCY)
.flatMap { candidates ->
candidates.map { baseVersion ->
async {
val buildsHtml = getHtmlPageOrNull(archiveUrlForCandidateBuilds(baseVersion))
?: return@async emptyList()
mozillaArchiveHtmlParser.parseCandidateBuildNumbersFromHtml(buildsHtml)
.map { buildNumber -> "$baseVersion-RC$buildNumber" }
}
}.awaitAll().flatten()
}
}
}

override suspend fun isPublished(url: String): Boolean = try {
// Only archive URLs are built from directory listings; others come from real asset links.
!url.startsWith(ARCHIVE_MOZILLA_BASE_URL) ||
Expand All @@ -296,12 +301,19 @@ class DefaultMozillaArchiveRepository(

private data class CandidateVersion(val baseVersion: String, val buildNumber: Int)

private suspend fun getHtmlPageOrNull(url: String): String? = try {
mozillaArchivesApiService.getHtmlPage(url)
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
null
private class CachedListing(val fetchedAt: Instant, val directories: List<String>)

/**
* Fetches an index page such as `releases/` as its directory names. Release and beta cards load
* concurrently and read the same listings, so a per-URL lock makes the second caller wait for
* the first's request, and successful results are reused for [LISTING_CACHE_TTL]; the short TTL
* keeps a pull-to-refresh fetching fresh data. Different URLs are fetched in parallel.
*/
private suspend fun getDirectoryListing(url: String): List<String> = listingLocks.getOrPut(url) { Mutex() }.withLock {
val now = clock.now()
listingCache[url]?.takeIf { now - it.fetchedAt < LISTING_CACHE_TTL }?.let { return@withLock it.directories }
mozillaArchiveHtmlParser.parseDirectoryNamesFromHtml(mozillaArchivesApiService.getHtmlPage(url))
.also { listingCache[url] = CachedListing(now, it) }
}

private fun parseCandidateVersion(version: String): CandidateVersion? {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class FenixBetaReleaseRepository(
return mozillaArchiveRepository.getFenixReleaseVersions(ReleaseType.Beta)
}

override suspend fun getCandidateBaseVersions(): NetworkResult<List<String>> {
return mozillaArchiveRepository.getFenixCandidateBaseVersions(ReleaseType.Beta)
}

override suspend fun getCandidateVersions(baseVersion: String): NetworkResult<List<String>> {
return mozillaArchiveRepository.getFenixCandidateVersions(baseVersion)
}

override suspend fun getReleasesForVersion(version: String): NetworkResult<List<MozillaArchiveApk>> {
return mozillaArchiveRepository.getFenixReleaseBuildsForVersion(version, ReleaseType.Beta)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,14 @@ class FenixReleaseReleaseRepository(
return mozillaArchiveRepository.getFenixReleaseVersions(ReleaseType.Release)
}

override suspend fun getCandidateBaseVersions(): NetworkResult<List<String>> {
return mozillaArchiveRepository.getFenixCandidateBaseVersions(ReleaseType.Release)
}

override suspend fun getCandidateVersions(baseVersion: String): NetworkResult<List<String>> {
return mozillaArchiveRepository.getFenixCandidateVersions(baseVersion)
}

override suspend fun getReleasesForVersion(version: String): NetworkResult<List<MozillaArchiveApk>> {
return mozillaArchiveRepository.getFenixReleaseBuildsForVersion(version, ReleaseType.Release)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ interface MozillaArchiveRepository {

suspend fun getFenixReleaseVersions(releaseType: ReleaseType = ReleaseType.Release): NetworkResult<List<String>>

/**
* Lists base versions (e.g. `157.0`, `158.0b4`) that have a `-candidates` directory. This is a
* single request; build numbers are resolved separately via [getFenixCandidateVersions].
*/
suspend fun getFenixCandidateBaseVersions(releaseType: ReleaseType): NetworkResult<List<String>>

/** Lists the RC versions (e.g. `157.0-RC2`, `157.0-RC1`) available for one candidate base version. */
suspend fun getFenixCandidateVersions(baseVersion: String): NetworkResult<List<String>>

suspend fun getFenixReleaseBuildsForVersion(
version: String,
releaseType: ReleaseType = ReleaseType.Release,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,5 +26,11 @@ interface DateAwareReleaseRepository : ReleaseRepository {
interface VersionAwareReleaseRepository : ReleaseRepository {
suspend fun getAvailableReleaseVersions(): NetworkResult<List<String>>

/** Base versions that have release candidates, from a single listing request. */
suspend fun getCandidateBaseVersions(): NetworkResult<List<String>> = NetworkResult.Success(emptyList())

/** RC versions for one candidate base version, selectable via [getReleasesForVersion]. */
suspend fun getCandidateVersions(baseVersion: String): NetworkResult<List<String>> = NetworkResult.Success(emptyList())

suspend fun getReleasesForVersion(version: String): NetworkResult<List<MozillaArchiveApk>>
}
Loading
Loading