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/SourceAwareStatusUpdater.kt 0 → 100644 +128 −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 foundation.e.apps.data.install.pkg.AppLoungePackageManager import foundation.e.apps.data.updates.OtherStoreOwnershipCache import foundation.e.apps.data.updates.TrustedStorePolicy import foundation.e.apps.domain.model.install.Status import foundation.e.apps.domain.preferences.AppPreferencesRepository import foundation.e.apps.domain.updates.GetUpdateEligibilityUseCase import foundation.e.apps.domain.updates.TrustedStore import foundation.e.apps.domain.updates.UpdateOwnership import foundation.e.apps.domain.updates.UpdateSource import javax.inject.Inject import javax.inject.Singleton /** * Refreshes [Application.status] so the UI shows an UPDATABLE button only when the rendered * source matches the installer/owner — keeping a Play card from offering an update for an * F-Droid build of the same package, and vice versa. */ @Singleton @OpenForTesting class SourceAwareStatusUpdater @Inject constructor( private val applicationDataManager: ApplicationDataManager, private val getUpdateEligibilityUseCase: GetUpdateEligibilityUseCase, private val appLoungePackageManager: AppLoungePackageManager, private val trustedStorePolicy: TrustedStorePolicy, private val appPreferencesRepository: AppPreferencesRepository, private val otherStoreOwnershipCache: OtherStoreOwnershipCache, ) { fun updateStatus(application: Application) { applicationDataManager.updateStatus(application) applyDisplayStatus(application) } fun applyDisplayStatus(application: Application) { application.status = getDisplayStatus(application, application.status) } fun getDisplayStatus(application: Application, rawStatus: Status): Status { // Skip the package-manager lookup for cases where the use case will return rawStatus // anyway: not UPDATABLE, PWA, or a neutral source. if (rawStatus != Status.UPDATABLE || application.source.isNeutralUpdateSource()) { return getUpdateEligibilityUseCase.displayStatus( eligibilityRequest(application, rawStatus, TrustedStore.TRUSTED_NEUTRAL, null, false) ) } val trustedStore = trustedStoreFor(application.package_name) val thirdPartyUpdatesEnabled = trustedStore == TrustedStore.NON_TRUSTED && appPreferencesRepository.shouldUpdateAppsFromOtherStores() return getUpdateEligibilityUseCase.displayStatus( eligibilityRequest( application = application, rawStatus = rawStatus, trustedStore = trustedStore, ownership = resolveOwnership(application, trustedStore, thirdPartyUpdatesEnabled), thirdPartyUpdatesEnabled = thirdPartyUpdatesEnabled, ), ) } private fun resolveOwnership( application: Application, trustedStore: TrustedStore, thirdPartyUpdatesEnabled: Boolean, ): UpdateOwnership? { if (trustedStore != TrustedStore.NON_TRUSTED || !thirdPartyUpdatesEnabled) { return null } return otherStoreOwnershipCache.classify(application.package_name) ?: UpdateOwnership.UNVERIFIED } private fun trustedStoreFor(packageName: String): TrustedStore { return trustedStorePolicy.classifyInstaller( appLoungePackageManager.getInstallerName(packageName) ) } private fun eligibilityRequest( application: Application, rawStatus: Status, trustedStore: TrustedStore, ownership: UpdateOwnership?, thirdPartyUpdatesEnabled: Boolean, ) = GetUpdateEligibilityUseCase.Request( renderedSource = application.source.toUpdateSource(), rawStatus = rawStatus, trustedStore = trustedStore, isThirdPartyUpdatesEnabled = thirdPartyUpdatesEnabled, ownership = ownership, isPwa = application.is_pwa, ) private fun Source.isNeutralUpdateSource(): Boolean { return this == Source.PWA || this == Source.SYSTEM_APP } private fun Source.toUpdateSource(): UpdateSource { return when (this) { Source.OPEN_SOURCE -> UpdateSource.OPEN_SOURCE Source.PLAY_STORE -> UpdateSource.PLAY_STORE Source.PWA, Source.SYSTEM_APP -> UpdateSource.NEUTRAL } } } app/src/main/java/foundation/e/apps/data/application/apps/AppsApiImpl.kt +9 −17 Original line number Diff line number Diff line Loading @@ -21,6 +21,7 @@ package foundation.e.apps.data.application.apps import foundation.e.apps.data.EnabledSourceState import foundation.e.apps.data.EnabledStoreRepositoryProvider import foundation.e.apps.data.application.ApplicationDataManager import foundation.e.apps.data.application.SourceAwareStatusUpdater import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.FilterLevel import foundation.e.apps.data.enums.ResultStatus Loading @@ -35,7 +36,8 @@ import javax.inject.Inject class AppsApiImpl @Inject constructor( private val enabledStoreRepositoryProvider: EnabledStoreRepositoryProvider, private val enabledSourceState: EnabledSourceState, private val applicationDataManager: ApplicationDataManager private val applicationDataManager: ApplicationDataManager, private val sourceAwareStatusUpdater: SourceAwareStatusUpdater, ) : AppsApi { override suspend fun getCleanapkAppDetails(packageName: String): Pair<Application, ResultStatus> { var application = Application() Loading Loading @@ -73,10 +75,10 @@ class AppsApiImpl @Inject constructor( response.first.forEach { if (it.package_name.isNotBlank()) { applicationDataManager.updateStatus(it) it.source = source sourceAwareStatusUpdater.updateStatus(it) it.updateType() list.add(it) it.source = source } } Loading Loading @@ -153,8 +155,8 @@ class AppsApiImpl @Inject constructor( application = store.getAppDetails(packageName) application.let { applicationDataManager.updateStatus(it) it.source = source sourceAwareStatusUpdater.updateStatus(it) it.updateType() it.updateFilterLevel() } Loading @@ -165,7 +167,8 @@ class AppsApiImpl @Inject constructor( } override fun getFusedAppInstallationStatus(application: Application): Status { return applicationDataManager.getFusedAppInstallationStatus(application) val rawStatus = applicationDataManager.getFusedAppInstallationStatus(application) return sourceAwareStatusUpdater.getDisplayStatus(application, rawStatus) } override suspend fun getAppFilterLevel( Loading Loading @@ -201,18 +204,7 @@ class AppsApiImpl @Inject constructor( } override fun isAnyAppInstallStatusChanged(currentList: List<Application>): Boolean { currentList.forEach { if (it.status == Status.INSTALLATION_ISSUE) { return@forEach } val currentAppStatus = getFusedAppInstallationStatus(it) if (it.status != currentAppStatus) { return true } } return false return currentList.any { it.status != getFusedAppInstallationStatus(it) } } override suspend fun isOpenSourceStoreEnabled(): Boolean { Loading app/src/main/java/foundation/e/apps/data/application/category/CategoryApiImpl.kt +11 −10 Original line number Diff line number Diff line Loading @@ -26,6 +26,7 @@ import foundation.e.apps.data.AppSourcesContainer import foundation.e.apps.data.EnabledStoreRepositoryProvider import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.application.ApplicationDataManager import foundation.e.apps.data.application.SourceAwareStatusUpdater import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.data.Category import foundation.e.apps.data.application.utils.CategoryType Loading @@ -45,7 +46,8 @@ class CategoryApiImpl @Inject constructor( @ApplicationContext private val context: Context, private val appSources: AppSourcesContainer, private val enabledStoreRepositoryProvider: EnabledStoreRepositoryProvider, private val applicationDataManager: ApplicationDataManager private val applicationDataManager: ApplicationDataManager, private val sourceAwareStatusUpdater: SourceAwareStatusUpdater, ) : CategoryApi { override suspend fun getCategoriesList(type: CategoryType): List<CategoriesResponse> { val categoryResponses = mutableListOf<CategoriesResponse>() Loading Loading @@ -206,14 +208,13 @@ class CategoryApiImpl @Inject constructor( ): ResultSupreme<List<Application>> { val filteredApplications = mutableListOf<Application>() return handleNetworkResult { appList.forEach { val filter = applicationDataManager.getAppFilterLevel(it.toApplication(context)) appList.forEach { app -> val application = app.toApplication(context).apply { source = Source.PLAY_STORE } val filter = applicationDataManager.getAppFilterLevel(application) if (filter.isUnFiltered()) { filteredApplications.add( it.toApplication(context).apply { this.filterLevel = filter } ) sourceAwareStatusUpdater.updateStatus(application) application.filterLevel = filter filteredApplications.add(application) } } filteredApplications Loading @@ -229,9 +230,9 @@ class CategoryApiImpl @Inject constructor( val response = getCleanApkAppsResponse(source, category) response?.apps?.forEach { applicationDataManager.updateStatus(it) it.updateType() it.source = source sourceAwareStatusUpdater.updateStatus(it) it.updateType() applicationDataManager.updateFilterLevel(it) list.add(it) } Loading app/src/main/java/foundation/e/apps/data/application/data/Application.kt +4 −3 Original line number Diff line number Diff line Loading @@ -112,10 +112,11 @@ data class Application( /* * CleanAPK signature version (e.g. "update_42") -> versionCode of the APK that was published * under that signature. Lets F-Droid ownership verification map an installed versionCode back * to the matching cleanapk signature directory without an extra round-trip. * under that signature. Populated by ApplicationDeserializer from the cleanapk response so the * F-Droid ownership verifier can map an installed versionCode back to a signature directory * without an extra round-trip. */ val cleanApkVersionCodeByDownloadVersion: Map<String, Long> = emptyMap(), var cleanApkVersionCodeByDownloadVersion: Map<String, Long> = emptyMap(), ) { val iconUrl: String? get() { Loading 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/SourceAwareStatusUpdater.kt 0 → 100644 +128 −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 foundation.e.apps.data.install.pkg.AppLoungePackageManager import foundation.e.apps.data.updates.OtherStoreOwnershipCache import foundation.e.apps.data.updates.TrustedStorePolicy import foundation.e.apps.domain.model.install.Status import foundation.e.apps.domain.preferences.AppPreferencesRepository import foundation.e.apps.domain.updates.GetUpdateEligibilityUseCase import foundation.e.apps.domain.updates.TrustedStore import foundation.e.apps.domain.updates.UpdateOwnership import foundation.e.apps.domain.updates.UpdateSource import javax.inject.Inject import javax.inject.Singleton /** * Refreshes [Application.status] so the UI shows an UPDATABLE button only when the rendered * source matches the installer/owner — keeping a Play card from offering an update for an * F-Droid build of the same package, and vice versa. */ @Singleton @OpenForTesting class SourceAwareStatusUpdater @Inject constructor( private val applicationDataManager: ApplicationDataManager, private val getUpdateEligibilityUseCase: GetUpdateEligibilityUseCase, private val appLoungePackageManager: AppLoungePackageManager, private val trustedStorePolicy: TrustedStorePolicy, private val appPreferencesRepository: AppPreferencesRepository, private val otherStoreOwnershipCache: OtherStoreOwnershipCache, ) { fun updateStatus(application: Application) { applicationDataManager.updateStatus(application) applyDisplayStatus(application) } fun applyDisplayStatus(application: Application) { application.status = getDisplayStatus(application, application.status) } fun getDisplayStatus(application: Application, rawStatus: Status): Status { // Skip the package-manager lookup for cases where the use case will return rawStatus // anyway: not UPDATABLE, PWA, or a neutral source. if (rawStatus != Status.UPDATABLE || application.source.isNeutralUpdateSource()) { return getUpdateEligibilityUseCase.displayStatus( eligibilityRequest(application, rawStatus, TrustedStore.TRUSTED_NEUTRAL, null, false) ) } val trustedStore = trustedStoreFor(application.package_name) val thirdPartyUpdatesEnabled = trustedStore == TrustedStore.NON_TRUSTED && appPreferencesRepository.shouldUpdateAppsFromOtherStores() return getUpdateEligibilityUseCase.displayStatus( eligibilityRequest( application = application, rawStatus = rawStatus, trustedStore = trustedStore, ownership = resolveOwnership(application, trustedStore, thirdPartyUpdatesEnabled), thirdPartyUpdatesEnabled = thirdPartyUpdatesEnabled, ), ) } private fun resolveOwnership( application: Application, trustedStore: TrustedStore, thirdPartyUpdatesEnabled: Boolean, ): UpdateOwnership? { if (trustedStore != TrustedStore.NON_TRUSTED || !thirdPartyUpdatesEnabled) { return null } return otherStoreOwnershipCache.classify(application.package_name) ?: UpdateOwnership.UNVERIFIED } private fun trustedStoreFor(packageName: String): TrustedStore { return trustedStorePolicy.classifyInstaller( appLoungePackageManager.getInstallerName(packageName) ) } private fun eligibilityRequest( application: Application, rawStatus: Status, trustedStore: TrustedStore, ownership: UpdateOwnership?, thirdPartyUpdatesEnabled: Boolean, ) = GetUpdateEligibilityUseCase.Request( renderedSource = application.source.toUpdateSource(), rawStatus = rawStatus, trustedStore = trustedStore, isThirdPartyUpdatesEnabled = thirdPartyUpdatesEnabled, ownership = ownership, isPwa = application.is_pwa, ) private fun Source.isNeutralUpdateSource(): Boolean { return this == Source.PWA || this == Source.SYSTEM_APP } private fun Source.toUpdateSource(): UpdateSource { return when (this) { Source.OPEN_SOURCE -> UpdateSource.OPEN_SOURCE Source.PLAY_STORE -> UpdateSource.PLAY_STORE Source.PWA, Source.SYSTEM_APP -> UpdateSource.NEUTRAL } } }
app/src/main/java/foundation/e/apps/data/application/apps/AppsApiImpl.kt +9 −17 Original line number Diff line number Diff line Loading @@ -21,6 +21,7 @@ package foundation.e.apps.data.application.apps import foundation.e.apps.data.EnabledSourceState import foundation.e.apps.data.EnabledStoreRepositoryProvider import foundation.e.apps.data.application.ApplicationDataManager import foundation.e.apps.data.application.SourceAwareStatusUpdater import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.FilterLevel import foundation.e.apps.data.enums.ResultStatus Loading @@ -35,7 +36,8 @@ import javax.inject.Inject class AppsApiImpl @Inject constructor( private val enabledStoreRepositoryProvider: EnabledStoreRepositoryProvider, private val enabledSourceState: EnabledSourceState, private val applicationDataManager: ApplicationDataManager private val applicationDataManager: ApplicationDataManager, private val sourceAwareStatusUpdater: SourceAwareStatusUpdater, ) : AppsApi { override suspend fun getCleanapkAppDetails(packageName: String): Pair<Application, ResultStatus> { var application = Application() Loading Loading @@ -73,10 +75,10 @@ class AppsApiImpl @Inject constructor( response.first.forEach { if (it.package_name.isNotBlank()) { applicationDataManager.updateStatus(it) it.source = source sourceAwareStatusUpdater.updateStatus(it) it.updateType() list.add(it) it.source = source } } Loading Loading @@ -153,8 +155,8 @@ class AppsApiImpl @Inject constructor( application = store.getAppDetails(packageName) application.let { applicationDataManager.updateStatus(it) it.source = source sourceAwareStatusUpdater.updateStatus(it) it.updateType() it.updateFilterLevel() } Loading @@ -165,7 +167,8 @@ class AppsApiImpl @Inject constructor( } override fun getFusedAppInstallationStatus(application: Application): Status { return applicationDataManager.getFusedAppInstallationStatus(application) val rawStatus = applicationDataManager.getFusedAppInstallationStatus(application) return sourceAwareStatusUpdater.getDisplayStatus(application, rawStatus) } override suspend fun getAppFilterLevel( Loading Loading @@ -201,18 +204,7 @@ class AppsApiImpl @Inject constructor( } override fun isAnyAppInstallStatusChanged(currentList: List<Application>): Boolean { currentList.forEach { if (it.status == Status.INSTALLATION_ISSUE) { return@forEach } val currentAppStatus = getFusedAppInstallationStatus(it) if (it.status != currentAppStatus) { return true } } return false return currentList.any { it.status != getFusedAppInstallationStatus(it) } } override suspend fun isOpenSourceStoreEnabled(): Boolean { Loading
app/src/main/java/foundation/e/apps/data/application/category/CategoryApiImpl.kt +11 −10 Original line number Diff line number Diff line Loading @@ -26,6 +26,7 @@ import foundation.e.apps.data.AppSourcesContainer import foundation.e.apps.data.EnabledStoreRepositoryProvider import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.application.ApplicationDataManager import foundation.e.apps.data.application.SourceAwareStatusUpdater import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.application.data.Category import foundation.e.apps.data.application.utils.CategoryType Loading @@ -45,7 +46,8 @@ class CategoryApiImpl @Inject constructor( @ApplicationContext private val context: Context, private val appSources: AppSourcesContainer, private val enabledStoreRepositoryProvider: EnabledStoreRepositoryProvider, private val applicationDataManager: ApplicationDataManager private val applicationDataManager: ApplicationDataManager, private val sourceAwareStatusUpdater: SourceAwareStatusUpdater, ) : CategoryApi { override suspend fun getCategoriesList(type: CategoryType): List<CategoriesResponse> { val categoryResponses = mutableListOf<CategoriesResponse>() Loading Loading @@ -206,14 +208,13 @@ class CategoryApiImpl @Inject constructor( ): ResultSupreme<List<Application>> { val filteredApplications = mutableListOf<Application>() return handleNetworkResult { appList.forEach { val filter = applicationDataManager.getAppFilterLevel(it.toApplication(context)) appList.forEach { app -> val application = app.toApplication(context).apply { source = Source.PLAY_STORE } val filter = applicationDataManager.getAppFilterLevel(application) if (filter.isUnFiltered()) { filteredApplications.add( it.toApplication(context).apply { this.filterLevel = filter } ) sourceAwareStatusUpdater.updateStatus(application) application.filterLevel = filter filteredApplications.add(application) } } filteredApplications Loading @@ -229,9 +230,9 @@ class CategoryApiImpl @Inject constructor( val response = getCleanApkAppsResponse(source, category) response?.apps?.forEach { applicationDataManager.updateStatus(it) it.updateType() it.source = source sourceAwareStatusUpdater.updateStatus(it) it.updateType() applicationDataManager.updateFilterLevel(it) list.add(it) } Loading
app/src/main/java/foundation/e/apps/data/application/data/Application.kt +4 −3 Original line number Diff line number Diff line Loading @@ -112,10 +112,11 @@ data class Application( /* * CleanAPK signature version (e.g. "update_42") -> versionCode of the APK that was published * under that signature. Lets F-Droid ownership verification map an installed versionCode back * to the matching cleanapk signature directory without an extra round-trip. * under that signature. Populated by ApplicationDeserializer from the cleanapk response so the * F-Droid ownership verifier can map an installed versionCode back to a signature directory * without an extra round-trip. */ val cleanApkVersionCodeByDownloadVersion: Map<String, Long> = emptyMap(), var cleanApkVersionCodeByDownloadVersion: Map<String, Long> = emptyMap(), ) { val iconUrl: String? get() { Loading