Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 1126ac9c authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

feat(updates): wire source-aware ownership into the update flow

parent ffc6d334
Loading
Loading
Loading
Loading
+1 −3
Original line number Diff line number Diff line
@@ -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.
+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
        }
    }
}
+9 −17
Original line number Diff line number Diff line
@@ -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
@@ -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()
@@ -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
            }
        }

@@ -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()
            }
@@ -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(
@@ -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 {
+11 −10
Original line number Diff line number Diff line
@@ -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
@@ -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>()
@@ -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
@@ -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)
            }
+4 −3
Original line number Diff line number Diff line
@@ -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