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
Expand Up @@ -8,7 +8,6 @@ import org.mozilla.tryfox.data.SearchHistoryQueryType
import org.mozilla.tryfox.data.repositories.UserDataRepository
import org.mozilla.tryfox.lan.LanReceiveIdentity
import org.mozilla.tryfox.model.HomeScreenLayout
import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI

/**
* A fake implementation of [org.mozilla.tryfox.data.repositories.UserDataRepository] for testing purposes.
Expand All @@ -23,8 +22,8 @@ class FakeUserDataRepository : UserDataRepository {
override val lanReceiveIdentityFlow: Flow<LanReceiveIdentity?> = _lanReceiveIdentityFlow
private val _homeScreenLayoutFlow = MutableStateFlow(HomeScreenLayout.OneCardPerApp)
override val homeScreenLayoutFlow: Flow<HomeScreenLayout> = _homeScreenLayoutFlow
private val _preferredAbiFlow = MutableStateFlow(DEFAULT_PREFERRED_ABI)
override val preferredAbiFlow: Flow<String> = _preferredAbiFlow
private val _preferredAbiFlow = MutableStateFlow<String?>(null)
override val preferredAbiFlow: Flow<String?> = _preferredAbiFlow

override suspend fun saveLastSearchedEmail(email: String) {
recordSearch("try", email)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,7 @@ class HomeAppCardTest {
apkDir = File("/tmp/fenix"),
),
),
selectedApkKey = "fenix/2024-12-31-09-17-32/fenix.apk",
),
),
),
Expand Down Expand Up @@ -240,6 +241,7 @@ class HomeAppCardTest {
apkDir = File("/tmp/focus"),
),
),
selectedApkKey = "focus-release/focus.apk",
),
),
),
Expand Down
42 changes: 42 additions & 0 deletions app/src/main/java/org/mozilla/tryfox/data/ArchiveApkDirectory.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package org.mozilla.tryfox.data

import org.mozilla.tryfox.model.MozillaArchiveApk

/**
* One APK build directory on archive.mozilla.org. Nightly and release listings share the naming
* scheme `[<timestamp>-]<app>-<version>-android[-<abi>]/`, where only nightlies have a timestamp
* and a directory without an ABI suffix holds the universal build.
*
* The APK file name is derived from the directory name and may not actually be published (e.g.
* some universal directories only hold an `.aab`), so check it before offering it.
*/
data class ArchiveApkDirectory(
val directory: String,
val timestamp: String?,
val appName: String,
val version: String,
val abi: String,
) {
val fileName: String get() = "$appName-$version.multi.android-$abi.apk"

/**
* @param listingUrl The URL of the listing this directory was found in.
* @param appName The app the APK is shown under, e.g. `fenix-release` for a `fenix` directory.
* @param displayVersion The version shown to the user, e.g. `157.0-RC2` for a candidate build.
* @param buildKey Isolates the download cache per build; nightlies use their timestamp.
*/
fun toApk(
listingUrl: String,
appName: String = this.appName,
displayVersion: String = version,
buildKey: String? = timestamp,
) = MozillaArchiveApk(
originalString = directory,
rawDateString = buildKey,
appName = appName,
version = displayVersion,
abiName = abi,
fullUrl = "$listingUrl$directory$fileName",
fileName = fileName,
)
}
147 changes: 59 additions & 88 deletions app/src/main/java/org/mozilla/tryfox/data/MozillaArchiveHtmlParser.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,45 +2,67 @@ package org.mozilla.tryfox.data

import kotlinx.datetime.LocalDate
import org.mozilla.tryfox.model.MozillaArchiveApk
import java.util.regex.Pattern
import org.mozilla.tryfox.util.UNIVERSAL_ABI

class MozillaArchiveHtmlParser {

private companion object {
// [<yyyy-MM-dd-HH-mm-ss>-]<app>-<version>-android[-<abi>]
val APK_DIRECTORY_PATTERN =
Regex("^(?:(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})-)?(.+?)-(\\d+\\.\\d+(?:\\.\\d+)?(?:[ab]\\d+)?)-android(?:-(.+))?$")
}

fun parseNightlyBuildsFromHtml(
html: String,
archiveUrl: String,
date: LocalDate?,
): List<MozillaArchiveApk> {
val htmlPattern = Regex("<td>Dir</td>\\s*<td><a href=\"[^\"]*\">([^<]+/)</a></td>")
val rawBuildStrings = htmlPattern.findAll(html)
.mapNotNull { it.groups[1]?.value }
.filter { it != "../" }
.toList()

val buildsForDate = if (date != null) {
val dateString = date.toString()
rawBuildStrings.filter { it.startsWith(dateString) }
} else {
val buildsByDay = rawBuildStrings.groupBy { it.substring(0, 10) }
if (buildsByDay.isEmpty()) return emptyList()
val latestDay = buildsByDay.keys.maxOrNull() ?: return emptyList()
buildsByDay[latestDay] ?: emptyList()
}
val builds = parseApkDirectoriesFromHtml(html).filter { it.timestamp != null }
val day = date?.toString() ?: builds.maxOfOrNull { it.timestamp!!.take(10) } ?: return emptyList()
return builds.filter { it.timestamp!!.startsWith(day) }.map { it.toApk(archiveUrl) }
}

return buildsForDate.mapNotNull { buildString ->
parseBuildString(buildString, archiveUrl)
}
/** Parses the APK build directories in an archive listing; see [ArchiveApkDirectory]. */
fun parseApkDirectoriesFromHtml(html: String): List<ArchiveApkDirectory> =
parseDirectoryNamesFromHtml(html).mapNotNull(::parseApkDirectory)

private fun parseApkDirectory(name: String): ArchiveApkDirectory? {
val match = APK_DIRECTORY_PATTERN.matchEntire(name) ?: return null
val (timestamp, appName, version, abi) = match.destructured
return ArchiveApkDirectory(
directory = "$name/",
timestamp = timestamp.ifEmpty { null },
appName = appName,
version = version,
abi = abi.ifEmpty { UNIVERSAL_ABI },
)
}

fun parseFenixReleasesFromHtml(html: String, releaseType: ReleaseType = ReleaseType.Beta): String {
return parseFenixReleaseVersionsFromHtml(html, releaseType).firstOrNull() ?: ""
}

fun parseFenixReleaseVersionsFromHtml(html: String, releaseType: ReleaseType = ReleaseType.Beta): List<String> {
val releasePattern = Regex("<a href=\"[^\"]+\">([0-9.]+[a-zA-Z0-9.-]*)/</a>")
val rawReleaseStrings = releasePattern.findAll(html)
.mapNotNull { it.groups[1]?.value }
.toList()
fun parseFenixReleaseVersionsFromHtml(html: String, releaseType: ReleaseType = ReleaseType.Beta): List<String> =
releaseVersionsFromDirectories(parseDirectoryNamesFromHtml(html), releaseType)

/** Returns candidate base versions, without their `-candidates` directory suffix. */
fun parseFenixCandidateVersionsFromHtml(html: String, releaseType: ReleaseType): List<String> =
candidateBaseVersionsFromDirectories(parseDirectoryNamesFromHtml(html), releaseType)

/**
* Extracts the directory names (without trailing `/`) from an archive index page. This is the
* channel-independent form of a listing, so one fetch can serve both Release and Beta, and
* it's what [parseApkDirectoriesFromHtml] reads build directories from.
*/
fun parseDirectoryNamesFromHtml(html: String): List<String> {
val directoryPattern = Regex("<a href=\"[^\"]+\">([^<]+)/</a>")
return directoryPattern.firstGroupOfAll(html)
}

/** Filters a `releases/` listing to the versions of [releaseType], newest first. */
fun releaseVersionsFromDirectories(directories: List<String>, releaseType: ReleaseType): List<String> {
val releasePattern = Regex("[0-9.]+[a-zA-Z0-9.-]*")
val rawReleaseStrings = directories.filter { it.matches(releasePattern) }

return when (releaseType) {
ReleaseType.Beta -> {
Expand All @@ -56,11 +78,9 @@ class MozillaArchiveHtmlParser {
}
}

/** Returns candidate base versions, without their `-candidates` directory suffix. */
fun parseFenixCandidateVersionsFromHtml(html: String, releaseType: ReleaseType): List<String> {
val directoryPattern = Regex("<a href=\"[^\"]+\">([^<]+/)</a>")
return directoryPattern.findAll(html)
.mapNotNull { it.groups[1]?.value?.removeSuffix("/") }
/** Filters a `candidates/` listing to the base versions of [releaseType], newest first. */
fun candidateBaseVersionsFromDirectories(directories: List<String>, releaseType: ReleaseType): List<String> {
return directories
.filter { it.endsWith("-candidates") }
.map { it.removeSuffix("-candidates") }
.filter { candidate ->
Expand All @@ -71,48 +91,16 @@ class MozillaArchiveHtmlParser {
}
.distinct()
.sortedWith(::compareReleaseVersions)
.toList()
.reversed()
}

/** Extracts numeric build directories such as `build1/` and `build12/`. */
fun parseCandidateBuildNumbersFromHtml(html: String): List<Int> {
val directoryPattern = Regex("<a href=\"[^\"]+\">build(\\d+)/</a>")
return directoryPattern.findAll(html)
.mapNotNull { it.groups[1]?.value?.toIntOrNull() }
return directoryPattern.firstGroupOfAll(html)
.mapNotNull { it.toIntOrNull() }
.distinct()
.sortedDescending()
.toList()
}

fun parseFenixReleaseAbisFromHtml(html: String, appName: String): List<String> {
// Pattern: {appName}-D+.D+(.D+)?-android-ABI/ or {appName}-D+.D+(.D+)?-android/
// Also supports beta/alpha markers: {appName}-D+.D+(.D+)?[ab]D+-android-ABI/
val htmlPattern = Regex("<td>Dir</td>\\s*<td><a href=\"[^\"]*\">([^<]+/)</a></td>")
val rawBuildStrings = htmlPattern.findAll(html)
.mapNotNull { it.groups[1]?.value }
.filter { it != "../" }
.toList()

val abis = mutableListOf<String>()

for (buildString in rawBuildStrings) {
// Pattern: {appName}-D+.D+(.D+)?[ab]D+-android-ABI/ or {appName}-D+.D+(.D+)?-android/
// Also supports: {appName}-D+.D+(.D+)?-android-ABI/ (stable releases)
// Examples:
// - fenix-145.0-android-arm64-v8a/ (stable)
// - fenix-145.0-android/ (stable universal)
// - fenix-146.0b5-android-arm64-v8a/ (beta)
// - fenix-146.0b5-android/ (beta universal)
val pattern = Regex("^$appName-\\d+\\.\\d+(?:\\.\\d+)?(?:[ab]\\d+)?-android(?:-(.+?))?/$")
val matchResult = pattern.find(buildString)

if (matchResult != null) {
val abi = matchResult.groups[1]?.value
abis.add(abi ?: "universal")
}
}
return abis
}

internal fun compareReleaseVersions(version1: String, version2: String): Int {
Expand Down Expand Up @@ -153,30 +141,13 @@ class MozillaArchiveHtmlParser {
return !isPreRelease && version.matches(Regex("\\d+\\.\\d+(\\.\\d+)?"))
}

private fun parseBuildString(buildString: String, archiveUrl: String): MozillaArchiveApk? {
val apkPattern =
Pattern.compile("^(\\d{4}-\\d{2}-\\d{2}-\\d{2}-\\d{2}-\\d{2})-(.*?)-([^-]+)-android-(.*?)/$")
val matcher = apkPattern.matcher(buildString)
if (matcher.matches()) {
val rawDate = matcher.group(1) ?: ""
val appNameResult = matcher.group(2) ?: ""
val version = matcher.group(3) ?: ""
val abi = matcher.group(4) ?: ""

val fileName = "$appNameResult-$version.multi.android-$abi.apk"
val fullUrl = "${archiveUrl}${buildString}$fileName"

return MozillaArchiveApk(
originalString = buildString,
rawDateString = rawDate,
appName = appNameResult,
version = version,
abiName = abi,
fullUrl = fullUrl,
fileName = fileName,
)
}

return null
/**
* The first capture group of every match. Kotlin's [Regex.findAll] creates a new Matcher per
* match, and on Android each one copies the whole input, which makes scanning a large listing
* cost matches x length (about 200 ms for `fenix/releases/`). One Matcher avoids that.
*/
private fun Regex.firstGroupOfAll(input: CharSequence): List<String> {
val matcher = toPattern().matcher(input)
return buildList { while (matcher.find()) add(matcher.group(1)) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import org.mozilla.tryfox.util.FOCUS
import org.mozilla.tryfox.util.FOCUS_BETA
import org.mozilla.tryfox.util.FOCUS_RELEASE
import retrofit2.HttpException
import java.net.HttpURLConnection

class DefaultMozillaArchiveRepository(
private val mozillaArchivesApiService: MozillaArchivesApiService,
Expand Down Expand Up @@ -245,50 +246,21 @@ class DefaultMozillaArchiveRepository(
archiveUrl: String,
archiveAppName: String,
resultAppName: String,
// Empty for releases; candidates need an isolated cache key.
cacheBuildKey: String,
): NetworkResult<List<MozillaArchiveApk>> {
val releaseHtml = mozillaArchivesApiService.getHtmlPage(archiveUrl)
val abis = mozillaArchiveHtmlParser.parseFenixReleaseAbisFromHtml(releaseHtml, archiveAppName)

if (abis.isEmpty()) {
return NetworkResult.Error("No ABIs found for release $version", null)
}

val apks = abis.map { abi ->
constructReleaseApk(version, displayVersion, abi, archiveUrl, archiveAppName, resultAppName, cacheBuildKey)
}
val apks = mozillaArchiveHtmlParser.parseApkDirectoriesFromHtml(releaseHtml)
.filter { it.appName == archiveAppName }
.map { it.toApk(archiveUrl, appName = resultAppName, displayVersion = displayVersion, buildKey = cacheBuildKey) }

if (apks.isEmpty()) {
return NetworkResult.Error("Failed to construct APKs for release $version", null)
return NetworkResult.Error("No ABIs found for release $version", null)
}

return NetworkResult.Success(apks)
}

private fun constructReleaseApk(
version: String,
displayVersion: String,
abi: String,
releaseBaseUrl: String,
archiveAppName: String,
resultAppName: String,
cacheBuildKey: String,
): MozillaArchiveApk {
val buildString = "$archiveAppName-$version-android${if (abi == "universal") "" else "-$abi"}/"
val fileName = "$archiveAppName-$version.multi.android-$abi.apk"
val fullUrl = "${releaseBaseUrl}${buildString}$fileName"

return MozillaArchiveApk(
originalString = buildString,
rawDateString = cacheBuildKey, // Empty for releases; candidates need an isolated cache key.
appName = resultAppName,
version = displayVersion,
abiName = abi,
fullUrl = fullUrl,
fileName = fileName,
)
}

private suspend fun fetchFenixCandidateVersions(
releaseType: ReleaseType,
): List<String> {
Expand All @@ -312,6 +284,16 @@ class DefaultMozillaArchiveRepository(
}
}

override suspend fun isPublished(url: String): Boolean = try {
// Only archive URLs are built from directory listings; others come from real asset links.
!url.startsWith(ARCHIVE_MOZILLA_BASE_URL) ||
mozillaArchivesApiService.head(url).code() != HttpURLConnection.HTTP_NOT_FOUND
} catch (e: CancellationException) {
throw e
} catch (_: Exception) {
true
}

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

private suspend fun getHtmlPageOrNull(url: String): String? = try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@ import org.mozilla.tryfox.data.SearchHistoryEntry
import org.mozilla.tryfox.data.SearchHistoryQueryType
import org.mozilla.tryfox.lan.LanReceiveIdentity
import org.mozilla.tryfox.model.HomeScreenLayout
import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI

/**
* A repository that stores the last searched email in a DataStore.
Expand Down Expand Up @@ -68,8 +67,8 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos
homeScreenLayoutFromStoredValue(preferences[PreferenceKeys.HOME_SCREEN_LAYOUT])
}

override val preferredAbiFlow: Flow<String> = appContext.dataStore.data.map { preferences ->
preferences[PreferenceKeys.PREFERRED_ABI]?.takeIf { it.isNotBlank() } ?: DEFAULT_PREFERRED_ABI
override val preferredAbiFlow: Flow<String?> = appContext.dataStore.data.map { preferences ->
preferences[PreferenceKeys.PREFERRED_ABI]?.takeIf { it.isNotBlank() }
}

override suspend fun saveLastSearchedEmail(email: String) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,10 @@ interface MozillaArchiveRepository {
suspend fun getFocusReleaseBuildsForVersion(version: String): NetworkResult<List<MozillaArchiveApk>>

suspend fun getFocusBetaBuildsForVersion(version: String): NetworkResult<List<MozillaArchiveApk>>

/**
* Whether a file is actually published at [url]. Only a definite "not found" from the archive
* counts as missing; other hosts and network errors return true so the download reports them.
*/
suspend fun isPublished(url: String): Boolean
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,11 @@ interface UserDataRepository {
val lanReceiveIdentityFlow: Flow<LanReceiveIdentity?>
val homeScreenLayoutFlow: Flow<HomeScreenLayout>

/** The ABI variant to favour when a build offers several. Emits `universal` by default. */
val preferredAbiFlow: Flow<String>
/**
* The ABI variant to favour when a build offers several, as picked in Settings. Emits null
* until the user picks one, so the device's default applies.
*/
val preferredAbiFlow: Flow<String?>

/**
* Saves the last searched email.
Expand Down
Loading
Loading