Loading app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt +1 −3 Original line number Diff line number Diff line Loading @@ -80,10 +80,8 @@ class ApplicationDataManager @Inject constructor( } fun updateStatus(application: Application) { if (application.status != Status.INSTALLATION_ISSUE) { application.status = getFusedAppInstallationStatus(application) } } /* * Get fused app installation status. Loading app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt +18 −0 Original line number Diff line number Diff line Loading @@ -49,6 +49,7 @@ class ApplicationRepository @Inject constructor( private val downloadInfoApi: DownloadInfoApi, private val enabledStoreRepositoryProvider: EnabledStoreRepositoryProvider, private val enabledSourceState: EnabledSourceState, private val playStoreOtherStoreStatusResolver: PlayStoreOtherStoreStatusResolver, ) { companion object { const val APP_TYPE_ANY = "any" Loading Loading @@ -108,6 +109,7 @@ class ApplicationRepository @Inject constructor( val result = handleNetworkResult { storeRepository.getHomeScreenData(list) } hydratePlayHomeUpdateStatus(source, list) prefixHomeErrorMessage(result, source) list.sortBy { Loading @@ -126,6 +128,14 @@ class ApplicationRepository @Inject constructor( ) } private suspend fun hydratePlayHomeUpdateStatus(source: Source, homes: List<Home>) { if (source != Source.PLAY_STORE) return playStoreOtherStoreStatusResolver.resolveAndApply( applications = homes.flatMap { it.list }, hydrateMissingDetails = true, ) } private fun prefixHomeErrorMessage(result: ResultSupreme<*>, source: Source) { if (result.getResultStatus() != ResultStatus.OK) { val prefix = when (source) { Loading Loading @@ -216,6 +226,14 @@ class ApplicationRepository @Inject constructor( return appsApi.getFusedAppInstallationStatus(application) } /** * Raw package-manager status, bypassing source-aware filtering. Use this when a UI needs to * know whether the underlying APK can update even if the rendered source isn't the owner. */ fun getRawFusedAppInstallationStatus(application: Application): Status { return appsApi.getRawFusedAppInstallationStatus(application) } fun isAnyFusedAppUpdated( newApplications: List<Application>, oldApplications: List<Application> Loading app/src/main/java/foundation/e/apps/data/application/PendingUpdatesStore.kt 0 → 100644 +28 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.application import foundation.e.apps.data.application.data.Application import javax.inject.Inject import javax.inject.Singleton @Singleton class PendingUpdatesStore @Inject constructor() { fun getPendingUpdates(): List<Application> = UpdatesDao.appsAwaitingForUpdate } app/src/main/java/foundation/e/apps/data/application/PlayStoreAppDetailsCache.kt 0 → 100644 +96 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.application import foundation.e.apps.OpenForTesting import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Source import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton /** * Session-scoped cache of Play app details. Returns defensive copies so consumers can't mutate * cached entries. Invalidated via package install/uninstall broadcasts (see PkgManagerBR). */ @Singleton @OpenForTesting class PlayStoreAppDetailsCache @Inject constructor() { private data class CachedDetails( val application: Application, val isComplete: Boolean, ) private val detailsByPackage = ConcurrentHashMap<String, CachedDetails>() fun get(packageName: String): Application? { return detailsByPackage[packageName]?.application?.copy() } suspend fun getOrFetch( packageName: String, provider: PlayStoreAppDetailsProvider, ): Application? { // Only a complete entry (from a single details fetch) can satisfy a detail-screen // request. A partial entry from the batch path lacks screenshots/description, so we // re-fetch the full details and upgrade the cache instead of returning it. detailsByPackage[packageName]?.let { cached -> if (cached.isComplete) return cached.application.copy() } val details = provider.getPlayStoreAppDetails(packageName) return if (details.package_name.isBlank()) { get(packageName) } else { put(details, isComplete = true) get(packageName) } } suspend fun getOrFetch( packageNames: List<String>, provider: PlayStoreAppDetailsProvider, ): Map<String, Application> { val distinct = packageNames.filter { it.isNotBlank() }.distinct() val cached = distinct.mapNotNull { name -> get(name)?.let { name to it } }.toMap() val missing = distinct - cached.keys if (missing.isEmpty()) return cached putAll(provider.getPlayStoreAppDetails(missing)) val freshlyCached = missing.mapNotNull { name -> get(name)?.let { name to it } }.toMap() return cached + freshlyCached } fun putAll(applications: List<Application>) { // Batch (bulkDetails) responses omit screenshots/description, so they are partial. applications.forEach { put(it, isComplete = false) } } fun put(application: Application, isComplete: Boolean = true) { if (application.package_name.isBlank()) return // Pin source to PLAY_STORE in case a provider returns the value pre-normalization. detailsByPackage[application.package_name] = CachedDetails(application.copy(source = Source.PLAY_STORE), isComplete) } fun clear() { detailsByPackage.clear() } } app/src/main/java/foundation/e/apps/data/application/PlayStoreAppDetailsHydrator.kt 0 → 100644 +87 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.application import foundation.e.apps.OpenForTesting import foundation.e.apps.data.EnabledStoreRepositoryProvider import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Source import foundation.e.apps.data.install.pkg.AppLoungePackageManager import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton /** * For installed Play apps that were hydrated from a lightweight source (e.g. home feed) * without a real latest_version_code, fetches full details so update buttons render correctly. */ @Singleton @OpenForTesting class PlayStoreAppDetailsHydrator @Inject constructor( private val enabledStoreRepositoryProvider: EnabledStoreRepositoryProvider, private val appLoungePackageManager: AppLoungePackageManager, private val playStoreAppDetailsCache: PlayStoreAppDetailsCache, ) { suspend fun hydrateAndApplyMissingDetails( applications: List<Application>, ): Map<String, Application> { val packagesNeedingDetails = applications .asSequence() .filter(::isMissingPlayDetailsCandidate) .map { it.package_name } .distinct() .toList() if (packagesNeedingDetails.isEmpty()) return emptyMap() val detailsByPackage = fetchDetails(packagesNeedingDetails) applications.forEach { application -> val details = detailsByPackage[application.package_name] ?: return@forEach if (details.latest_version_code > application.latest_version_code) { application.latest_version_code = details.latest_version_code } } return detailsByPackage } private suspend fun fetchDetails(packageNames: List<String>): Map<String, Application> { val provider = getPlayStoreDetailsProvider() ?: return emptyMap() return runCatching { playStoreAppDetailsCache.getOrFetch(packageNames, provider) }.getOrElse { exception -> if (exception is CancellationException) throw exception Timber.w(exception, "Failed to hydrate installed Play app details") emptyMap() } } private fun isMissingPlayDetailsCandidate(application: Application): Boolean { return application.source == Source.PLAY_STORE && !application.is_pwa && application.package_name.isNotBlank() && application.latest_version_code <= 0 && appLoungePackageManager.isInstalled(application.package_name) } private suspend fun getPlayStoreDetailsProvider(): PlayStoreAppDetailsProvider? { return enabledStoreRepositoryProvider.awaitStore(Source.PLAY_STORE) as? PlayStoreAppDetailsProvider } } Loading
app/src/main/java/foundation/e/apps/data/application/ApplicationDataManager.kt +1 −3 Original line number Diff line number Diff line Loading @@ -80,10 +80,8 @@ class ApplicationDataManager @Inject constructor( } fun updateStatus(application: Application) { if (application.status != Status.INSTALLATION_ISSUE) { application.status = getFusedAppInstallationStatus(application) } } /* * Get fused app installation status. Loading
app/src/main/java/foundation/e/apps/data/application/ApplicationRepository.kt +18 −0 Original line number Diff line number Diff line Loading @@ -49,6 +49,7 @@ class ApplicationRepository @Inject constructor( private val downloadInfoApi: DownloadInfoApi, private val enabledStoreRepositoryProvider: EnabledStoreRepositoryProvider, private val enabledSourceState: EnabledSourceState, private val playStoreOtherStoreStatusResolver: PlayStoreOtherStoreStatusResolver, ) { companion object { const val APP_TYPE_ANY = "any" Loading Loading @@ -108,6 +109,7 @@ class ApplicationRepository @Inject constructor( val result = handleNetworkResult { storeRepository.getHomeScreenData(list) } hydratePlayHomeUpdateStatus(source, list) prefixHomeErrorMessage(result, source) list.sortBy { Loading @@ -126,6 +128,14 @@ class ApplicationRepository @Inject constructor( ) } private suspend fun hydratePlayHomeUpdateStatus(source: Source, homes: List<Home>) { if (source != Source.PLAY_STORE) return playStoreOtherStoreStatusResolver.resolveAndApply( applications = homes.flatMap { it.list }, hydrateMissingDetails = true, ) } private fun prefixHomeErrorMessage(result: ResultSupreme<*>, source: Source) { if (result.getResultStatus() != ResultStatus.OK) { val prefix = when (source) { Loading Loading @@ -216,6 +226,14 @@ class ApplicationRepository @Inject constructor( return appsApi.getFusedAppInstallationStatus(application) } /** * Raw package-manager status, bypassing source-aware filtering. Use this when a UI needs to * know whether the underlying APK can update even if the rendered source isn't the owner. */ fun getRawFusedAppInstallationStatus(application: Application): Status { return appsApi.getRawFusedAppInstallationStatus(application) } fun isAnyFusedAppUpdated( newApplications: List<Application>, oldApplications: List<Application> Loading
app/src/main/java/foundation/e/apps/data/application/PendingUpdatesStore.kt 0 → 100644 +28 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.application import foundation.e.apps.data.application.data.Application import javax.inject.Inject import javax.inject.Singleton @Singleton class PendingUpdatesStore @Inject constructor() { fun getPendingUpdates(): List<Application> = UpdatesDao.appsAwaitingForUpdate }
app/src/main/java/foundation/e/apps/data/application/PlayStoreAppDetailsCache.kt 0 → 100644 +96 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.application import foundation.e.apps.OpenForTesting import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Source import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton /** * Session-scoped cache of Play app details. Returns defensive copies so consumers can't mutate * cached entries. Invalidated via package install/uninstall broadcasts (see PkgManagerBR). */ @Singleton @OpenForTesting class PlayStoreAppDetailsCache @Inject constructor() { private data class CachedDetails( val application: Application, val isComplete: Boolean, ) private val detailsByPackage = ConcurrentHashMap<String, CachedDetails>() fun get(packageName: String): Application? { return detailsByPackage[packageName]?.application?.copy() } suspend fun getOrFetch( packageName: String, provider: PlayStoreAppDetailsProvider, ): Application? { // Only a complete entry (from a single details fetch) can satisfy a detail-screen // request. A partial entry from the batch path lacks screenshots/description, so we // re-fetch the full details and upgrade the cache instead of returning it. detailsByPackage[packageName]?.let { cached -> if (cached.isComplete) return cached.application.copy() } val details = provider.getPlayStoreAppDetails(packageName) return if (details.package_name.isBlank()) { get(packageName) } else { put(details, isComplete = true) get(packageName) } } suspend fun getOrFetch( packageNames: List<String>, provider: PlayStoreAppDetailsProvider, ): Map<String, Application> { val distinct = packageNames.filter { it.isNotBlank() }.distinct() val cached = distinct.mapNotNull { name -> get(name)?.let { name to it } }.toMap() val missing = distinct - cached.keys if (missing.isEmpty()) return cached putAll(provider.getPlayStoreAppDetails(missing)) val freshlyCached = missing.mapNotNull { name -> get(name)?.let { name to it } }.toMap() return cached + freshlyCached } fun putAll(applications: List<Application>) { // Batch (bulkDetails) responses omit screenshots/description, so they are partial. applications.forEach { put(it, isComplete = false) } } fun put(application: Application, isComplete: Boolean = true) { if (application.package_name.isBlank()) return // Pin source to PLAY_STORE in case a provider returns the value pre-normalization. detailsByPackage[application.package_name] = CachedDetails(application.copy(source = Source.PLAY_STORE), isComplete) } fun clear() { detailsByPackage.clear() } }
app/src/main/java/foundation/e/apps/data/application/PlayStoreAppDetailsHydrator.kt 0 → 100644 +87 −0 Original line number Diff line number Diff line /* * Copyright (C) 2026 e Foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see <https://www.gnu.org/licenses/>. * */ package foundation.e.apps.data.application import foundation.e.apps.OpenForTesting import foundation.e.apps.data.EnabledStoreRepositoryProvider import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.Source import foundation.e.apps.data.install.pkg.AppLoungePackageManager import kotlinx.coroutines.CancellationException import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton /** * For installed Play apps that were hydrated from a lightweight source (e.g. home feed) * without a real latest_version_code, fetches full details so update buttons render correctly. */ @Singleton @OpenForTesting class PlayStoreAppDetailsHydrator @Inject constructor( private val enabledStoreRepositoryProvider: EnabledStoreRepositoryProvider, private val appLoungePackageManager: AppLoungePackageManager, private val playStoreAppDetailsCache: PlayStoreAppDetailsCache, ) { suspend fun hydrateAndApplyMissingDetails( applications: List<Application>, ): Map<String, Application> { val packagesNeedingDetails = applications .asSequence() .filter(::isMissingPlayDetailsCandidate) .map { it.package_name } .distinct() .toList() if (packagesNeedingDetails.isEmpty()) return emptyMap() val detailsByPackage = fetchDetails(packagesNeedingDetails) applications.forEach { application -> val details = detailsByPackage[application.package_name] ?: return@forEach if (details.latest_version_code > application.latest_version_code) { application.latest_version_code = details.latest_version_code } } return detailsByPackage } private suspend fun fetchDetails(packageNames: List<String>): Map<String, Application> { val provider = getPlayStoreDetailsProvider() ?: return emptyMap() return runCatching { playStoreAppDetailsCache.getOrFetch(packageNames, provider) }.getOrElse { exception -> if (exception is CancellationException) throw exception Timber.w(exception, "Failed to hydrate installed Play app details") emptyMap() } } private fun isMissingPlayDetailsCandidate(application: Application): Boolean { return application.source == Source.PLAY_STORE && !application.is_pwa && application.package_name.isNotBlank() && application.latest_version_code <= 0 && appLoungePackageManager.isInstalled(application.package_name) } private suspend fun getPlayStoreDetailsProvider(): PlayStoreAppDetailsProvider? { return enabledStoreRepositoryProvider.awaitStore(Source.PLAY_STORE) as? PlayStoreAppDetailsProvider } }