diff --git a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt index 35f29a9..74ee0d8 100644 --- a/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt +++ b/app/src/androidTest/java/org/mozilla/tryfox/data/FakeUserDataRepository.kt @@ -8,6 +8,7 @@ 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. @@ -22,6 +23,8 @@ class FakeUserDataRepository : UserDataRepository { override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow private val _homeScreenLayoutFlow = MutableStateFlow(HomeScreenLayout.OneCardPerApp) override val homeScreenLayoutFlow: Flow = _homeScreenLayoutFlow + private val _preferredAbiFlow = MutableStateFlow(DEFAULT_PREFERRED_ABI) + override val preferredAbiFlow: Flow = _preferredAbiFlow override suspend fun saveLastSearchedEmail(email: String) { recordSearch("try", email) @@ -44,6 +47,10 @@ class FakeUserDataRepository : UserDataRepository { _homeScreenLayoutFlow.value = layout } + override suspend fun savePreferredAbi(abiName: String) { + _preferredAbiFlow.value = abiName + } + // Helper method for tests to clear the stored email if needed fun clearLastSearchedEmail() { _lastSearchedEmailFlow.value = "" diff --git a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt index 0a86507..73af1c0 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/DefaultMozillaPackageManager.kt @@ -34,6 +34,7 @@ import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.REFERENCE_BROWSER_PACKAGE import org.mozilla.tryfox.util.TRYFOX import org.mozilla.tryfox.util.TRYFOX_PACKAGE +import org.mozilla.tryfox.util.abiFromNativeLibraryDir class DefaultMozillaPackageManager(private val context: Context) : MozillaPackageManager { @@ -61,6 +62,7 @@ class DefaultMozillaPackageManager(private val context: Context) : MozillaPackag versionCode = packageInfo?.let { PackageInfoCompat.getLongVersionCode(it) }, installDateMillis = packageInfo?.lastUpdateTime, installingPackageName = if (packageInfo != null) getInstallingPackageName(packageName) else null, + activeAbi = abiFromNativeLibraryDir(packageInfo?.applicationInfo?.nativeLibraryDir), splitNames = packageInfo?.applicationInfo?.splitNames?.toList() ?: emptyList(), ) } diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt index a8329fd..7d51d3d 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/DefaultUserDataRepository.kt @@ -16,6 +16,7 @@ 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. @@ -31,6 +32,7 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos val LAN_DEVICE_NAME = stringPreferencesKey("lan_device_name") val LAN_SHARED_SECRET = stringPreferencesKey("lan_shared_secret") val HOME_SCREEN_LAYOUT = stringPreferencesKey("home_screen_layout") + val PREFERRED_ABI = stringPreferencesKey("preferred_abi") } override val searchHistoryFlow: Flow> = appContext.dataStore.data.map { preferences -> @@ -66,6 +68,10 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos homeScreenLayoutFromStoredValue(preferences[PreferenceKeys.HOME_SCREEN_LAYOUT]) } + override val preferredAbiFlow: Flow = appContext.dataStore.data.map { preferences -> + preferences[PreferenceKeys.PREFERRED_ABI]?.takeIf { it.isNotBlank() } ?: DEFAULT_PREFERRED_ABI + } + override suspend fun saveLastSearchedEmail(email: String) { recordSearch(project = "try", query = email) } @@ -102,6 +108,12 @@ class DefaultUserDataRepository(private val appContext: Context) : UserDataRepos } } + override suspend fun savePreferredAbi(abiName: String) { + appContext.dataStore.edit { preferences -> + preferences[PreferenceKeys.PREFERRED_ABI] = abiName + } + } + private fun decodeSearchHistory(serializedHistory: String): List = runCatching { json.decodeFromString>(serializedHistory) }.getOrDefault(emptyList()) diff --git a/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt b/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt index 4ad6628..5b9c2ba 100644 --- a/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt +++ b/app/src/main/java/org/mozilla/tryfox/data/repositories/UserDataRepository.kt @@ -18,6 +18,9 @@ interface UserDataRepository { val lanReceiveIdentityFlow: Flow val homeScreenLayoutFlow: Flow + /** The ABI variant to favour when a build offers several. Emits `universal` by default. */ + val preferredAbiFlow: Flow + /** * Saves the last searched email. * @param email The email to save. @@ -26,4 +29,5 @@ interface UserDataRepository { suspend fun recordSearch(project: String, query: String, searchedAt: Long = System.currentTimeMillis()) suspend fun saveLanReceiveIdentity(identity: LanReceiveIdentity) suspend fun saveHomeScreenLayout(layout: HomeScreenLayout) + suspend fun savePreferredAbi(abiName: String) } diff --git a/app/src/main/java/org/mozilla/tryfox/model/AppState.kt b/app/src/main/java/org/mozilla/tryfox/model/AppState.kt index 517aa11..1bbd440 100644 --- a/app/src/main/java/org/mozilla/tryfox/model/AppState.kt +++ b/app/src/main/java/org/mozilla/tryfox/model/AppState.kt @@ -11,6 +11,7 @@ data class AppState( val installDateMillis: Long?, val installingPackageName: String? = null, val versionCode: Long? = null, + val activeAbi: String? = null, val splitNames: List = emptyList(), ) { val isInstalled: Boolean 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 a08061c..fca2bc8 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 @@ -80,6 +80,8 @@ import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.AppState import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.NightlyBuildOption +import org.mozilla.tryfox.ui.models.preferredAbiApk +import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_RELEASE @@ -124,6 +126,7 @@ fun ArchiveGroupCard( onDismissBuildPicker: () -> Unit = {}, installStates: Map = emptyMap(), onOpenInstalledApp: (String) -> Unit = {}, + preferredAbi: String = DEFAULT_PREFERRED_ABI, ) { if (pendingBuildOptions.isNotEmpty()) { NightlyBuildPickerDialog( @@ -200,6 +203,7 @@ fun ArchiveGroupCard( appState, installStates, onOpenInstalledApp, + preferredAbi, ) } @@ -623,9 +627,13 @@ private fun ArchiveGroupAbiSelector( appState: AppState?, installStates: Map, onOpenInstalledApp: (String) -> Unit, + preferredAbi: String, ) { - val firstSupportedIndex = apks.indexOfFirst { it.abi.isSupported }.takeIf { it != -1 } ?: 0 - var selectedIndex by remember { mutableStateOf(firstSupportedIndex) } + val preferredIndex = apks.preferredAbiApk(preferredAbi) + ?.let(apks::indexOf) + ?.takeIf { it != -1 } + ?: 0 + var selectedIndex by remember { mutableStateOf(preferredIndex) } Column( horizontalAlignment = Alignment.CenterHorizontally, diff --git a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt index 0e1617c..f917871 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/composables/CurrentInstallState.kt @@ -172,6 +172,13 @@ private fun InstallMetadataBottomSheet( label = stringResource(id = R.string.install_metadata_source_label), value = installSourceLabel(appState), ) + InstallMetadataRow( + // The ABI Android selected at install time. A universal APK reports the single + // architecture the platform kept, so this does not identify the APK variant. + label = stringResource(id = R.string.install_metadata_abi_label), + value = appState.activeAbi + ?: stringResource(id = R.string.install_metadata_abi_none), + ) InstallMetadataBlock( label = stringResource(id = R.string.install_metadata_splits_label), // splitNames covers only the config splits; the base APK is always present but not @@ -233,7 +240,7 @@ private fun InstallMetadataBlock(label: String, value: String) { text = value, style = MaterialTheme.typography.bodySmall, fontFamily = FontFamily.Monospace, - modifier = Modifier.padding(top = 2.dp), + modifier = Modifier.padding(start = 8.dp, top = 2.dp), ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/AbiUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/AbiUiModel.kt index 8111e33..2e23007 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/AbiUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/AbiUiModel.kt @@ -1,6 +1,21 @@ package org.mozilla.tryfox.ui.models +import org.mozilla.tryfox.util.isUniversalAbi + data class AbiUiModel( val name: String?, val isSupported: Boolean, -) +) { + val isUniversal: Boolean get() = isUniversalAbi(name) +} + +/** + * Picks the variant matching [preferredAbi], falling back to universal, then to anything the + * device can install, then to whatever came first. Nightly builds ship no universal variant, so + * an unmatched affinity still resolves to an installable APK. + */ +fun List.preferredAbiApk(preferredAbi: String): ApkUiModel? = + firstOrNull { preferredAbi.equals(it.abi.name, ignoreCase = true) } + ?: firstOrNull { it.abi.isUniversal } + ?: firstOrNull { it.abi.isSupported } + ?: firstOrNull() diff --git a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt index 5bb2323..339425d 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/models/AppUiModel.kt @@ -27,6 +27,7 @@ data class AppUiModel( val installedDate: String?, val installingPackageName: String? = null, val splitNames: List = emptyList(), + val activeAbi: String? = null, val installedTryBuild: InstalledTryBuild? = null, val apks: ApksResult, val userPickedDate: LocalDate? = null, 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 6a7aa7c..fa4ee4e 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeAppCard.kt @@ -67,6 +67,8 @@ import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.ui.models.NightlyBuildOption +import org.mozilla.tryfox.ui.models.preferredAbiApk +import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG @@ -94,6 +96,7 @@ internal fun HomeAppCard( onBuildSelected: (String, String) -> Unit, onDismissBuildPicker: (String) -> Unit, modifier: Modifier = Modifier, + preferredAbi: String = DEFAULT_PREFERRED_ABI, ) { val app = card.selectedApp val appState = app.toAppState() @@ -104,8 +107,7 @@ internal fun HomeAppCard( val isNightly = app.name == FENIX || app.name == FOCUS val isDebug = app.name == FENIX_DEBUG || app.name == FOCUS_DEBUG val isVersionSelectable = app.name in setOf(FENIX_RELEASE, FENIX_BETA, FOCUS_RELEASE, FOCUS_BETA) - val selectedApk = (app.apks as? ApksResult.Success)?.apks - ?.let { apks -> apks.firstOrNull { it.abi.isSupported } ?: apks.firstOrNull() } + val selectedApk = (app.apks as? ApksResult.Success)?.apks?.preferredAbiApk(preferredAbi) Card( modifier = modifier @@ -340,6 +342,7 @@ private fun AppUiModel.toAppState(): AppState? = installedVersion?.let { version installDateMillis = installedDate?.let(::parseDateToMillis), installingPackageName = installingPackageName, versionCode = installedVersionCode, + activeAbi = activeAbi, splitNames = splitNames, ) } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt index 09b2ab9..83e9fb8 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreen.kt @@ -221,6 +221,7 @@ fun HomeScreen( onDismissBuildPicker = { appName -> homeViewModel.onDismissBuildPicker(appName) }, + preferredAbi = currentScreenState.preferredAbi, ) } } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt index 58f5668..1beda2f 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeScreenState.kt @@ -3,6 +3,7 @@ package org.mozilla.tryfox.ui.screens import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.HomeScreenLayout import org.mozilla.tryfox.ui.models.AppUiModel +import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI /** * Represents the various states of the Home screen. @@ -23,5 +24,6 @@ sealed class HomeScreenState { val isDownloadingAnyFile: Boolean, val selectedAppNames: Map = HomeAppFamily.entries.associateWith { it.defaultAppName }, val homeScreenLayout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, + val preferredAbi: String = DEFAULT_PREFERRED_ABI, ) : HomeScreenState() } diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/HomeViewModel.kt index abd3120..11a440f 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 @@ -50,6 +50,7 @@ import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.ui.models.AppUiModel import org.mozilla.tryfox.ui.models.NightlyBuildOption import org.mozilla.tryfox.ui.models.newVersionAvailable +import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_BETA import org.mozilla.tryfox.util.FENIX_DEBUG @@ -60,6 +61,8 @@ import org.mozilla.tryfox.util.FOCUS_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TRYFOX +import org.mozilla.tryfox.util.isAbiSupported +import org.mozilla.tryfox.util.resolvePreferredAbi import java.io.File /** @@ -100,6 +103,9 @@ class HomeViewModel( private var tryFoxCardDismissed = false private var homeScreenLayout = HomeScreenLayout.OneCardPerApp + @Volatile + private var preferredAbi = DEFAULT_PREFERRED_ABI + @Volatile private var selectedHomeAppNames = HomeAppFamily.entries.associateWith { it.defaultAppName } private val appMutationVersions = mutableMapOf() @@ -139,6 +145,20 @@ class HomeViewModel( } ?.launchIn(viewModelScope) + userDataRepository?.preferredAbiFlow + ?.onEach { storedAbi -> + val resolvedAbi = resolvePreferredAbi(storedAbi, supportedAbis) + preferredAbi = resolvedAbi + _homeScreenState.update { currentState -> + if (currentState is HomeScreenState.Loaded) { + currentState.copy(preferredAbi = resolvedAbi) + } else { + currentState + } + } + } + ?.launchIn(viewModelScope) + mozillaPackageManager.appStates .onEach { appState -> _homeScreenState.update { currentState -> @@ -151,6 +171,7 @@ class HomeViewModel( installedVersionCode = appState.versionCode, installedDate = appState.formattedInstallDate, installingPackageName = appState.installingPackageName, + activeAbi = appState.activeAbi, splitNames = appState.splitNames, installedTryBuild = app.name.takeIf { it == FENIX_DEBUG } ?.let { matchingInstalledTryBuild(appState) }, @@ -327,6 +348,7 @@ class HomeViewModel( installedDate = appState?.formattedInstallDate, installingPackageName = appState?.installingPackageName, splitNames = appState?.splitNames ?: emptyList(), + activeAbi = appState?.activeAbi, installedTryBuild = appState?.takeIf { name == FENIX_DEBUG } ?.let { matchingInstalledTryBuild(it) }, ) @@ -343,6 +365,7 @@ class HomeViewModel( isDownloadingAnyFile = false, selectedAppNames = selectedHomeAppNames, homeScreenLayout = homeScreenLayout, + preferredAbi = preferredAbi, ).applyDownloadStates(downloadStates.value) } @@ -401,6 +424,7 @@ class HomeViewModel( installedDate = appState?.formattedInstallDate, installingPackageName = appState?.installingPackageName, splitNames = appState?.splitNames.orEmpty(), + activeAbi = appState?.activeAbi, installedTryBuild = appState?.takeIf { appName == FENIX_DEBUG }?.let(::matchingInstalledTryBuild), apks = ApksResult.Success(apks.map { it.toUiModel() }), selectedReleaseVersion = selectedReleaseVersion, @@ -476,6 +500,7 @@ class HomeViewModel( installedDate = appState?.formattedInstallDate, installingPackageName = appState?.installingPackageName, splitNames = appState?.splitNames ?: emptyList(), + activeAbi = appState?.activeAbi, installedTryBuild = appState?.takeIf { repository.appName == FENIX_DEBUG }?.let(::matchingInstalledTryBuild), apks = apksResult, selectedReleaseVersion = selectedReleaseVersion, @@ -487,12 +512,8 @@ class HomeViewModel( return parsedApks.map { parsedApk -> val buildDate = parsedApk.rawDateString?.rawNightlyBuildDate() val date = buildDate?.let { parsedApk.rawDateString?.formatNightlyBuildDate().orEmpty() }.orEmpty() - val isCompatible = supportedAbis.any { deviceAbi -> - deviceAbi.equals( - parsedApk.abiName, - ignoreCase = true, - ) - } + // Universal APKs bundle every architecture, so they install anywhere. + val isCompatible = isAbiSupported(parsedApk.abiName, supportedAbis) // Key the cache dir / unique key by the full build timestamp (yyyy-MM-dd-HH-mm-ss) so // two Nightly builds from the same day don't collide on a date-only path. Releases have diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt index cb44e48..0ca37ca 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsScreen.kt @@ -43,6 +43,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.testTag import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.selected @@ -54,6 +55,7 @@ import org.mozilla.tryfox.data.managers.NotificationManager import org.mozilla.tryfox.data.managers.NotificationPermissionState import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.HomeScreenLayout +import org.mozilla.tryfox.util.isUniversalAbi @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -154,6 +156,12 @@ fun SettingsScreen( selectedLayout = uiState.homeScreenLayout, onLayoutSelected = settingsViewModel::selectHomeScreenLayout, ) + HorizontalDivider(modifier = Modifier.padding(horizontal = 16.dp)) + AbiSettingsCard( + selectedAbi = uiState.preferredAbi, + abiOptions = uiState.abiOptions, + onAbiSelected = settingsViewModel::selectPreferredAbi, + ) } } } @@ -260,6 +268,50 @@ private fun HomeLayoutSettingsCard( } } +@Composable +private fun AbiSettingsCard( + selectedAbi: String, + abiOptions: List, + onAbiSelected: (String) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + SettingsSectionTitle(R.string.settings_abi_section_title) + Text( + text = stringResource(R.string.settings_abi_description), + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + modifier = Modifier.padding(horizontal = 16.dp), + ) + PreferenceGroup { + abiOptions.forEachIndexed { index, abiName -> + if (index > 0) HorizontalDivider(modifier = Modifier.padding(start = 16.dp)) + val selected = abiName.equals(selectedAbi, ignoreCase = true) + ListItem( + headlineContent = { + Text( + if (isUniversalAbi(abiName)) { + stringResource(R.string.settings_abi_universal_label) + } else { + abiName + }, + ) + }, + supportingContent = if (isUniversalAbi(abiName)) { + { Text(stringResource(R.string.settings_abi_universal_description)) } + } else { + null + }, + trailingContent = { RadioButton(selected = selected, onClick = null) }, + modifier = Modifier + .semantics { this.selected = selected } + .testTag("settings_abi_option_$abiName") + .clickable(role = Role.RadioButton) { onAbiSelected(abiName) }, + ) + } + } + } +} + @Composable private fun PreferenceGroup(content: @Composable ColumnScope.() -> Unit) { Card( diff --git a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt index bc42da2..df5fb1a 100644 --- a/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt +++ b/app/src/main/java/org/mozilla/tryfox/ui/screens/SettingsViewModel.kt @@ -1,5 +1,6 @@ package org.mozilla.tryfox.ui.screens +import android.os.Build import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import kotlinx.coroutines.flow.SharingStarted @@ -12,12 +13,17 @@ import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.download.ApkDownloadCoordinator import org.mozilla.tryfox.model.CacheManagementState import org.mozilla.tryfox.model.HomeScreenLayout +import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI +import org.mozilla.tryfox.util.abiPreferenceOptions +import org.mozilla.tryfox.util.resolvePreferredAbi data class SettingsUiState( val cacheState: CacheManagementState = CacheManagementState.IdleEmpty, val cacheSizeBytes: Long = 0L, val hasActiveDownloads: Boolean = false, val homeScreenLayout: HomeScreenLayout = HomeScreenLayout.OneCardPerApp, + val preferredAbi: String = DEFAULT_PREFERRED_ABI, + val abiOptions: List = listOf(DEFAULT_PREFERRED_ABI), ) { val canClearCache: Boolean get() = cacheState == CacheManagementState.IdleNonEmpty && !hasActiveDownloads @@ -27,20 +33,26 @@ class SettingsViewModel( private val cacheManager: CacheManager, downloadCoordinator: ApkDownloadCoordinator, private val userDataRepository: UserDataRepository, + supportedAbis: List = runCatching { Build.SUPPORTED_ABIS.toList() }.getOrDefault(emptyList()), ) : ViewModel() { + private val abiOptions = abiPreferenceOptions(supportedAbis) + val uiState: StateFlow = combine( cacheManager.cacheState, cacheManager.cacheSizeBytes, downloadCoordinator.downloads, userDataRepository.homeScreenLayoutFlow, - ) { cacheState, cacheSizeBytes, downloads, homeScreenLayout -> + userDataRepository.preferredAbiFlow, + ) { cacheState, cacheSizeBytes, downloads, homeScreenLayout, preferredAbi -> SettingsUiState( cacheState = cacheState, cacheSizeBytes = cacheSizeBytes, hasActiveDownloads = downloads.values.any { !it.isTerminal }, homeScreenLayout = homeScreenLayout, + preferredAbi = resolvePreferredAbi(preferredAbi, supportedAbis), + abiOptions = abiOptions, ) - }.stateIn(viewModelScope, SharingStarted.Eagerly, SettingsUiState()) + }.stateIn(viewModelScope, SharingStarted.Eagerly, SettingsUiState(abiOptions = abiOptions)) init { viewModelScope.launch { cacheManager.checkCacheStatus() } @@ -54,4 +66,9 @@ class SettingsViewModel( fun selectHomeScreenLayout(layout: HomeScreenLayout) { viewModelScope.launch { userDataRepository.saveHomeScreenLayout(layout) } } + + fun selectPreferredAbi(abiName: String) { + if (abiName !in abiOptions) return + viewModelScope.launch { userDataRepository.savePreferredAbi(abiName) } + } } diff --git a/app/src/main/java/org/mozilla/tryfox/util/Abi.kt b/app/src/main/java/org/mozilla/tryfox/util/Abi.kt new file mode 100644 index 0000000..261d774 --- /dev/null +++ b/app/src/main/java/org/mozilla/tryfox/util/Abi.kt @@ -0,0 +1,59 @@ +package org.mozilla.tryfox.util + +// Retired with NDK r17; the platform still advertises these so pre-ARMv7 APKs keep working, but +// nothing is built for them any more, so offering them would only ever fall back to universal. +private val LEGACY_ABIS = setOf("armeabi", "mips", "mips64") + +/** The ABI name the archive uses for the APK that bundles every architecture. */ +const val UNIVERSAL_ABI = "universal" + +/** The ABI affinity used until the user picks one: universal installs on any device. */ +const val DEFAULT_PREFERRED_ABI = UNIVERSAL_ABI + +fun isUniversalAbi(abiName: String?): Boolean = UNIVERSAL_ABI.equals(abiName, ignoreCase = true) + +/** True when an APK built for [abiName] can be installed on a device running [deviceAbis]. */ +fun isAbiSupported(abiName: String?, deviceAbis: List): Boolean = + isUniversalAbi(abiName) || deviceAbis.any { it.equals(abiName, ignoreCase = true) } + +/** + * Determine the active ABI for installed package from `ApplicationInfo.nativeLibraryDir` path. + * + * This corresponds to what ABI was chosen by package manager when the package was installed. The + * package itself may have included multiple ABIs, but installer selected a single one that the + * application will execute under. + */ +fun abiFromNativeLibraryDir(nativeLibraryDir: String?): String? { + // The library names don't always match ABI so patch up the ones we know. + val directoryAbis = mapOf( + "arm64" to "arm64-v8a", + "arm" to "armeabi-v7a", + "x86_64" to "x86_64", + "x86" to "x86", + ) + return nativeLibraryDir + ?.trimEnd('/') + ?.substringAfterLast('/') + ?.lowercase() + // A package with no native code stops at the "lib" root, with no architecture below it. + ?.takeIf { it.isNotBlank() && it != "lib" } + ?.let { directory -> directoryAbis[directory] ?: directory } +} + +/** + * The affinity options offered in Settings: universal first, then every ABI the device runs that + * an APK could plausibly be built for. + */ +fun abiPreferenceOptions(deviceAbis: List): List { + val filteredAbis = deviceAbis + .filterNot { isUniversalAbi(it) || it.lowercase() in LEGACY_ABIS } + .distinct() + return listOf(UNIVERSAL_ABI) + filteredAbis +} + +/** Falls back to universal when the stored ABI is blank or no longer supported by this device. */ +fun resolvePreferredAbi(preferredAbi: String?, deviceAbis: List): String = + preferredAbi + ?.takeIf { it.isNotBlank() } + ?.takeIf { abiPreferenceOptions(deviceAbis).any { option -> option.equals(it, ignoreCase = true) } } + ?: DEFAULT_PREFERRED_ABI diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64fb7c5..603c317 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,6 +39,10 @@ Choose how apps are grouped on Home. One card per app One card per flavor of each app + Preferred ABI + Which APK variant to offer when a build ships more than one. Builds without this variant fall back to universal. + Universal + Every architecture in one APK Search builds Project Email or revision @@ -135,6 +139,8 @@ Version code Installed Source + Active ABI + none Google Play Store Sideloaded Splits diff --git a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt index cf8e8f3..3897376 100644 --- a/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt +++ b/app/src/test/java/org/mozilla/tryfox/data/managers/FakeUserDataRepository.kt @@ -8,6 +8,7 @@ import org.mozilla.tryfox.data.SearchHistoryQueryType import org.mozilla.tryfox.data.repositories.UserDataRepository import org.mozilla.tryfox.lan.LanReceiveIdentity import org.mozilla.tryfox.model.HomeScreenLayout +import org.mozilla.tryfox.util.DEFAULT_PREFERRED_ABI /** * A fake implementation of [UserDataRepository] for testing purposes. @@ -22,6 +23,8 @@ class FakeUserDataRepository : UserDataRepository { override val lanReceiveIdentityFlow: Flow = _lanReceiveIdentityFlow private val _homeScreenLayoutFlow = MutableStateFlow(HomeScreenLayout.OneCardPerApp) override val homeScreenLayoutFlow: Flow = _homeScreenLayoutFlow + private val _preferredAbiFlow = MutableStateFlow(DEFAULT_PREFERRED_ABI) + override val preferredAbiFlow: Flow = _preferredAbiFlow override suspend fun saveLastSearchedEmail(email: String) { recordSearch("try", email) @@ -44,6 +47,10 @@ class FakeUserDataRepository : UserDataRepository { _homeScreenLayoutFlow.value = layout } + override suspend fun savePreferredAbi(abiName: String) { + _preferredAbiFlow.value = abiName + } + // Helper method for tests to clear the stored email if needed fun clearLastSearchedEmail() { _lastSearchedEmailFlow.value = "" 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 45a06a9..9a8db65 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 @@ -61,6 +61,7 @@ import org.mozilla.tryfox.model.MozillaArchiveApk import org.mozilla.tryfox.ui.models.AbiUiModel import org.mozilla.tryfox.ui.models.ApkUiModel import org.mozilla.tryfox.ui.models.ApksResult +import org.mozilla.tryfox.ui.models.preferredAbiApk import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_DEBUG import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE @@ -70,6 +71,7 @@ import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER import org.mozilla.tryfox.util.TRYFOX +import org.mozilla.tryfox.util.UNIVERSAL_ABI import java.io.File @ExperimentalCoroutinesApi @@ -231,6 +233,43 @@ class HomeViewModelTest { assertEquals(HomeScreenLayout.OneCardPerFlavor, state.homeScreenLayout) } + @Test + fun `preferred ABI reaches the loaded state and universal APKs count as supported`() = runTest { + val universalApk = createTestParsedReleaseApk(version = "153.0", abi = UNIVERSAL_ABI) + val arm64Apk = createTestParsedReleaseApk(version = "153.0", abi = "arm64-v8a") + val userDataRepository = FakeUserDataRepository() + val viewModel = createViewModel( + releaseRepositories = listOf( + FenixReleaseReleaseRepository( + FakeMozillaArchiveRepository( + fenixReleaseVersions = NetworkResult.Success(listOf("153.0")), + fenixReleasesByVersion = mapOf( + "153.0" to NetworkResult.Success(listOf(arm64Apk, universalApk)), + ), + ), + ), + ), + userDataRepository = userDataRepository, + ) + + viewModel.initialLoad() + advanceUntilIdle() + + val initialState = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertEquals(UNIVERSAL_ABI, initialState.preferredAbi) + val apks = (initialState.apps.getValue(FENIX_RELEASE).apks as ApksResult.Success).apks + // Universal bundles every architecture, so it must never be flagged as unsupported. + assertTrue(apks.single { it.abi.name == UNIVERSAL_ABI }.abi.isSupported) + assertEquals(UNIVERSAL_ABI, apks.preferredAbiApk(initialState.preferredAbi)?.abi?.name) + + userDataRepository.savePreferredAbi("arm64-v8a") + advanceUntilIdle() + + val updatedState = viewModel.homeScreenState.value as HomeScreenState.Loaded + assertEquals("arm64-v8a", updatedState.preferredAbi) + assertEquals("arm64-v8a", apks.preferredAbiApk(updatedState.preferredAbi)?.abi?.name) + } + private class FakeHomeDataCacheRepository( var snapshot: HomeDataSnapshot? = null, ) : HomeDataCacheRepository { diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt index f9294b9..13e76eb 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/SettingsViewModelTest.kt @@ -19,6 +19,7 @@ import org.mozilla.tryfox.download.ApkDownloadRequest import org.mozilla.tryfox.download.model.DownloadStatus import org.mozilla.tryfox.download.model.PersistedDownloadState import org.mozilla.tryfox.model.HomeScreenLayout +import org.mozilla.tryfox.util.UNIVERSAL_ABI import java.io.File @OptIn(ExperimentalCoroutinesApi::class) @@ -61,6 +62,45 @@ class SettingsViewModelTest { assertEquals(0L, viewModel.uiState.value.cacheSizeBytes) } + @Test + fun `preferred ABI defaults to universal and offers every device ABI`() = runTest { + val userData = FakeUserDataRepository() + val viewModel = SettingsViewModel( + FakeCacheManager(tempDir), + FakeDownloadCoordinator(), + userData, + supportedAbis = listOf("arm64-v8a", "armeabi-v7a"), + ) + advanceUntilIdle() + + assertEquals(UNIVERSAL_ABI, viewModel.uiState.value.preferredAbi) + assertEquals(listOf(UNIVERSAL_ABI, "arm64-v8a", "armeabi-v7a"), viewModel.uiState.value.abiOptions) + + viewModel.selectPreferredAbi("arm64-v8a") + advanceUntilIdle() + assertEquals("arm64-v8a", viewModel.uiState.value.preferredAbi) + + // An ABI this device cannot run is never persisted. + viewModel.selectPreferredAbi("x86_64") + advanceUntilIdle() + assertEquals("arm64-v8a", viewModel.uiState.value.preferredAbi) + } + + @Test + fun `a stored ABI the device no longer runs resolves back to universal`() = runTest { + val userData = FakeUserDataRepository() + userData.savePreferredAbi("x86_64") + val viewModel = SettingsViewModel( + FakeCacheManager(tempDir), + FakeDownloadCoordinator(), + userData, + supportedAbis = listOf("arm64-v8a"), + ) + advanceUntilIdle() + + assertEquals(UNIVERSAL_ABI, viewModel.uiState.value.preferredAbi) + } + private class FakeDownloadCoordinator : ApkDownloadCoordinator { private val states = MutableStateFlow>(emptyMap()) override val downloads = states diff --git a/app/src/test/java/org/mozilla/tryfox/util/AbiPreferenceTest.kt b/app/src/test/java/org/mozilla/tryfox/util/AbiPreferenceTest.kt new file mode 100644 index 0000000..c734b20 --- /dev/null +++ b/app/src/test/java/org/mozilla/tryfox/util/AbiPreferenceTest.kt @@ -0,0 +1,100 @@ +package org.mozilla.tryfox.util + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.ui.models.AbiUiModel +import org.mozilla.tryfox.ui.models.ApkUiModel +import org.mozilla.tryfox.ui.models.preferredAbiApk +import java.io.File + +class AbiPreferenceTest { + + private val deviceAbis = listOf("arm64-v8a", "armeabi-v7a", "armeabi") + + @Test + fun `universal installs on any device and unknown ABIs do not`() { + assertTrue(isAbiSupported(UNIVERSAL_ABI, deviceAbis)) + assertTrue(isAbiSupported("Universal", deviceAbis)) + assertTrue(isAbiSupported("arm64-v8a", deviceAbis)) + assertFalse(isAbiSupported("x86_64", deviceAbis)) + assertFalse(isAbiSupported(null, deviceAbis)) + } + + @Test + fun `options list universal first then every buildable device ABI`() { + // The archive publishes arm64-v8a, armeabi-v7a, x86_64 and universal; legacy armeabi is + // still advertised by the platform but nothing targets it, so it is not offered. + assertEquals(listOf(UNIVERSAL_ABI, "arm64-v8a", "armeabi-v7a"), abiPreferenceOptions(deviceAbis)) + assertEquals(listOf(UNIVERSAL_ABI, "x86_64", "x86"), abiPreferenceOptions(listOf("x86_64", "x86"))) + assertEquals(listOf(UNIVERSAL_ABI), abiPreferenceOptions(emptyList())) + assertEquals(listOf(UNIVERSAL_ABI), abiPreferenceOptions(listOf("armeabi", "mips64"))) + assertEquals(listOf(UNIVERSAL_ABI, "arm64-v8a"), abiPreferenceOptions(listOf("arm64-v8a", "arm64-v8a"))) + } + + @Test + fun `stored affinity falls back to universal when blank or foreign to this device`() { + assertEquals("arm64-v8a", resolvePreferredAbi("arm64-v8a", deviceAbis)) + assertEquals(UNIVERSAL_ABI, resolvePreferredAbi(UNIVERSAL_ABI, deviceAbis)) + assertEquals(UNIVERSAL_ABI, resolvePreferredAbi(null, deviceAbis)) + assertEquals(UNIVERSAL_ABI, resolvePreferredAbi("", deviceAbis)) + // Recorded on an x86 emulator, then restored onto an ARM device. + assertEquals(UNIVERSAL_ABI, resolvePreferredAbi("x86_64", deviceAbis)) + // The device runs armeabi, but it is no longer an option, so it resolves back to universal. + assertEquals(UNIVERSAL_ABI, resolvePreferredAbi("armeabi", deviceAbis)) + } + + @Test + fun `installed ABI is read from the native library directory`() { + val installDir = "/data/app/~~xY==/org.mozilla.firefox-aB==" + assertEquals("arm64-v8a", abiFromNativeLibraryDir("$installDir/lib/arm64")) + assertEquals("armeabi-v7a", abiFromNativeLibraryDir("$installDir/lib/arm")) + assertEquals("x86_64", abiFromNativeLibraryDir("$installDir/lib/x86_64")) + assertEquals("x86", abiFromNativeLibraryDir("$installDir/lib/x86/")) + // An unrecognised directory is reported as-is rather than dropped. + assertEquals("riscv64", abiFromNativeLibraryDir("$installDir/lib/riscv64")) + assertNull(abiFromNativeLibraryDir(null)) + assertNull(abiFromNativeLibraryDir("")) + // A package with no native code has no architecture subdirectory, so no active ABI. + assertNull(abiFromNativeLibraryDir("$installDir/lib")) + } + + @Test + fun `preferred variant wins and universal is the fallback`() { + val releaseApks = listOf(apk("arm64-v8a"), apk("armeabi-v7a"), apk(UNIVERSAL_ABI)) + + assertEquals(UNIVERSAL_ABI, releaseApks.preferredAbiApk(UNIVERSAL_ABI)?.abi?.name) + assertEquals("arm64-v8a", releaseApks.preferredAbiApk("arm64-v8a")?.abi?.name) + // x86_64 has no variant here, so the universal APK still installs. + assertEquals(UNIVERSAL_ABI, releaseApks.preferredAbiApk("x86_64")?.abi?.name) + } + + @Test + fun `nightly builds without a universal variant fall back to a supported ABI`() { + val nightlyApks = listOf( + apk("x86_64", isSupported = false), + apk("arm64-v8a"), + ) + + assertEquals("arm64-v8a", nightlyApks.preferredAbiApk(UNIVERSAL_ABI)?.abi?.name) + assertEquals("arm64-v8a", nightlyApks.preferredAbiApk("arm64-v8a")?.abi?.name) + assertEquals("x86_64", nightlyApks.preferredAbiApk("x86_64")?.abi?.name) + assertNull(emptyList().preferredAbiApk(UNIVERSAL_ABI)) + } + + private fun apk(abiName: String, isSupported: Boolean = true) = ApkUiModel( + originalString = "fenix-153.0-android-$abiName/", + date = "", + appName = FENIX_RELEASE, + version = "153.0", + abi = AbiUiModel(abiName, isSupported), + url = "https://archive.invalid/fenix-153.0.multi.android-$abiName.apk", + fileName = "fenix-153.0.multi.android-$abiName.apk", + downloadState = DownloadState.NotDownloaded, + uniqueKey = "$FENIX_RELEASE/fenix-153.0.multi.android-$abiName.apk", + apkDir = File("."), + ) +}