From 726b0afd6251b50b4aff1a23cac216cd40a77b2d Mon Sep 17 00:00:00 2001 From: Titouan Thibaud Date: Tue, 11 Aug 2026 18:50:33 +0200 Subject: [PATCH] Fix Home install state after external uninstall --- .../mozilla/tryfox/ui/screens/HomeAppCard.kt | 12 ++++- .../mozilla/tryfox/ui/screens/HomeScreen.kt | 15 +++++++ .../tryfox/ui/screens/HomeViewModel.kt | 44 ++++++++++++++++--- .../ui/screens/FakeMozillaPackageManager.kt | 8 +++- .../tryfox/ui/screens/HomeAppCardModelTest.kt | 37 ++++++++++++++++ .../tryfox/ui/screens/HomeViewModelTest.kt | 39 ++++++++++++++++ 6 files changed, 146 insertions(+), 9 deletions(-) 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..2f841cf 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 @@ -217,7 +217,9 @@ internal fun HomeAppCard( downloadState = selectedApk?.downloadState ?: DownloadState.NotDownloaded, onDownloadClick = { selectedApk?.let(onDownloadClick) }, onInstallClick = { selectedApk?.let(onInstallClick) }, - installState = selectedApk?.let { installStates[it.uniqueKey] } ?: InstallState.Idle, + installState = selectedApk?.let { + effectiveInstallState(app, it, installStates) + } ?: InstallState.Idle, installDisabled = selectedApk == null, onOpenClick = onOpenInstalledApp, debugLabel = "home-card:${selectedApk?.uniqueKey ?: app.name}", @@ -270,6 +272,14 @@ internal fun HomeAppCard( } } +internal fun effectiveInstallState( + app: AppUiModel, + apk: ApkUiModel, + installStates: Map, +): InstallState = installStates[apk.uniqueKey] + ?.takeUnless { it is InstallState.Installed && app.installedVersion == null } + ?: InstallState.Idle + private const val SHORT_REVISION_LENGTH = 12 @OptIn(ExperimentalMaterial3Api::class) 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 18ad44f..fc410ec 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 @@ -28,6 +28,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBarDefaults import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -41,6 +42,9 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.unit.dp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.lifecycle.viewmodel.compose.viewModel import org.mozilla.tryfox.R @@ -69,11 +73,22 @@ fun HomeScreen( val isRefreshing by homeViewModel.isRefreshing.collectAsState() val installStates by homeViewModel.installStates.collectAsState() val pullRefreshState = rememberPullRefreshState(isRefreshing, { homeViewModel.refreshData() }) + val lifecycleOwner = LocalLifecycleOwner.current LaunchedEffect(Unit) { homeViewModel.initialLoad() } + DisposableEffect(lifecycleOwner, homeViewModel) { + val observer = LifecycleEventObserver { _, event -> + if (event == Lifecycle.Event.ON_RESUME) { + homeViewModel.refreshInstalledAppStates() + } + } + lifecycleOwner.lifecycle.addObserver(observer) + onDispose { lifecycleOwner.lifecycle.removeObserver(observer) } + } + Scaffold( modifier = modifier.fillMaxSize(), topBar = { 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..be64ac3 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 @@ -197,9 +197,32 @@ class HomeViewModel( } fun refreshData() { + if (!initialLoadStarted) { + initialLoad() + return + } launchRefresh(hydrateCache = false) } + fun refreshInstalledAppStates() { + viewModelScope.launch(ioDispatcher) { + val appInfoMap = appInfoMap() + synchronized(appsLock) { + currentAppsByName = currentAppsByName.mapValues { (appName, app) -> + app.withInstalledState(appInfoMap[appName]) + } + } + _homeScreenState.update { currentState -> + if (currentState !is HomeScreenState.Loaded) return@update currentState + currentState.copy( + apps = currentState.apps.mapValues { (appName, app) -> + app.withInstalledState(appInfoMap[appName]) + }, + ) + } + } + } + fun selectHomeAppFlavor(family: HomeAppFamily, appName: String) { if (appName !in family.appNames) return selectedHomeAppNames = selectedHomeAppNames + (family to appName) @@ -289,17 +312,24 @@ class HomeViewModel( AppUiModel( name = appName, packageName = appState.packageName, - installedVersion = appState.version, - installedVersionCode = appState.versionCode, - installedDate = appState.formattedInstallDate, - installingPackageName = appState.installingPackageName, - splitNames = appState.splitNames, - installedTryBuild = appName.takeIf { it == FENIX_DEBUG } - ?.let { matchingInstalledTryBuild(appState) }, + installedVersion = null, + installedDate = null, apks = ApksResult.Loading, ) + .withInstalledState(appState) } + private fun AppUiModel.withInstalledState(appState: AppState?): AppUiModel = copy( + packageName = appState?.packageName ?: packageName, + installedVersion = appState?.version, + installedVersionCode = appState?.versionCode, + installedDate = appState?.formattedInstallDate, + installingPackageName = appState?.installingPackageName, + splitNames = appState?.splitNames ?: emptyList(), + installedTryBuild = appState?.takeIf { name == FENIX_DEBUG } + ?.let { matchingInstalledTryBuild(it) }, + ) + private fun publishCurrentApps() { val apps = synchronized(appsLock) { currentAppsByName } val currentCacheState = cacheManager.cacheState.value diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt index e5cfd3d..2796202 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/FakeMozillaPackageManager.kt @@ -15,9 +15,15 @@ import org.mozilla.tryfox.util.REFERENCE_BROWSER_PACKAGE import org.mozilla.tryfox.util.TRYFOX_PACKAGE class FakeMozillaPackageManager( - private val apps: Map = emptyMap(), + apps: Map = emptyMap(), ) : MozillaPackageManager { + private val apps = apps.toMutableMap() + + fun setAppState(appState: AppState) { + apps[appState.packageName] = appState + } + override val fenix: AppState get() = apps[FENIX_NIGHTLY_PACKAGE] ?: AppState("Fenix", FENIX_NIGHTLY_PACKAGE, null, null) diff --git a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt index fe6f7e0..7cfafb5 100644 --- a/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt +++ b/app/src/test/java/org/mozilla/tryfox/ui/screens/HomeAppCardModelTest.kt @@ -2,7 +2,11 @@ package org.mozilla.tryfox.ui.screens import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test +import org.mozilla.tryfox.data.DownloadState +import org.mozilla.tryfox.install.InstallState import org.mozilla.tryfox.model.HomeScreenLayout +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.AppUiModel import org.mozilla.tryfox.util.FENIX @@ -14,6 +18,7 @@ import org.mozilla.tryfox.util.FOCUS_BETA import org.mozilla.tryfox.util.FOCUS_DEBUG import org.mozilla.tryfox.util.FOCUS_RELEASE import org.mozilla.tryfox.util.REFERENCE_BROWSER +import java.io.File class HomeAppCardModelTest { @Test @@ -79,6 +84,24 @@ class HomeAppCardModelTest { assertEquals(true, cards.all { !it.showFlavorSelector }) } + @Test + fun `ignores stale installed state when package is no longer installed`() { + val app = app(FENIX) + val apk = apk() + val installState = mapOf(apk.uniqueKey to InstallState.Installed(FENIX)) + + assertEquals(InstallState.Idle, effectiveInstallState(app, apk, installState)) + } + + @Test + fun `keeps installed state when package is still installed`() { + val app = app(FENIX, installedVersion = "1.0") + val apk = apk() + val installState = mapOf(apk.uniqueKey to InstallState.Installed(FENIX)) + + assertEquals(InstallState.Installed(FENIX), effectiveInstallState(app, apk, installState)) + } + private fun app(name: String, installedVersion: String? = null) = AppUiModel( name = name, packageName = name, @@ -86,4 +109,18 @@ class HomeAppCardModelTest { installedDate = null, apks = ApksResult.Loading, ) + + private fun apk() = ApkUiModel( + originalString = "fenix.apk", + date = "", + buildDate = null, + appName = FENIX, + version = "1.0", + abi = AbiUiModel("arm64-v8a", true), + url = "https://example.com/fenix.apk", + fileName = "fenix.apk", + downloadState = DownloadState.Downloaded(File("fenix.apk")), + uniqueKey = "fenix/fenix.apk", + apkDir = File("."), + ) } 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..bb3b5cc 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 @@ -64,6 +64,7 @@ import org.mozilla.tryfox.ui.models.ApksResult import org.mozilla.tryfox.util.FENIX import org.mozilla.tryfox.util.FENIX_DEBUG import org.mozilla.tryfox.util.FENIX_DEBUG_PACKAGE +import org.mozilla.tryfox.util.FENIX_NIGHTLY_PACKAGE import org.mozilla.tryfox.util.FENIX_RELEASE import org.mozilla.tryfox.util.FOCUS import org.mozilla.tryfox.util.FOCUS_RELEASE @@ -468,6 +469,44 @@ class HomeViewModelTest { assertEquals(1, repository.calls) } + @Test + fun `refresh rereads package state after installed app is removed outside TryFox`() = runTest { + val packageManager = FakeMozillaPackageManager( + mapOf( + FENIX_NIGHTLY_PACKAGE to AppState( + "Fenix", + FENIX_NIGHTLY_PACKAGE, + "125.0a1", + 1L, + ), + ), + ) + val repository = CountingReleaseRepository( + testFenixAppName, + NetworkResult.Success(emptyList()), + ) + viewModel = createViewModel( + releaseRepositories = listOf(repository), + mozillaPackageManager = packageManager, + ) + + viewModel.initialLoad() + advanceUntilIdle() + assertEquals( + "125.0a1", + (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps.getValue(FENIX).installedVersion, + ) + + packageManager.setAppState(AppState("Fenix", FENIX_NIGHTLY_PACKAGE, null, null)) + viewModel.refreshInstalledAppStates() + advanceUntilIdle() + + assertNull( + (viewModel.homeScreenState.value as HomeScreenState.Loaded).apps.getValue(FENIX).installedVersion, + ) + assertFalse(viewModel.isRefreshing.value) + } + @Test fun `refresh waits for an in-flight load before starting another request`() = runTest { val repository = BlockingDateReleaseRepository(