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

Commit 8611da61 authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

feat(updates): Properly handle sideloaded apps and make them eligible for update

parent af3b76f5
Loading
Loading
Loading
Loading
Loading
+27 −18
Original line number Diff line number Diff line
@@ -34,9 +34,9 @@ import javax.inject.Singleton

/**
 * Bridges browsing surfaces (home feed, app detail, search results) into the source-aware
 * update flow for Play apps that may be installed via an untrusted installer. Hydrates
 * missing Play details when requested, runs the ownership verifier for unresolved packages,
 * and re-applies display status so an UPDATE button only shows when ownership is verified.
 * update flow for Play apps that may be installed without a trusted store. Hydrates missing
 * Play details when requested, verifies sideloaded APK signing certs against Play metadata,
 * and re-applies display status.
 */
@Singleton
@OpenForTesting
@@ -65,14 +65,14 @@ class PlayStoreOtherStoreStatusResolver @Inject constructor(

        val updatablePackages = applications
            .asSequence()
            .filter(::isPlayOtherStoreUpdateCandidate)
            .distinctBy { it.package_name }
            .filter(::isPlaySideloadUpdateCandidate)
            .map { it.package_name }
            .distinct()
            .toList()

        if (updatablePackages.isEmpty()) return Result(hydratedDetailsByPackage)

        resolveMissingOwnership(updatablePackages)
        resolveMissingPlayOwnership(updatablePackages)
        val updatableSet = updatablePackages.toSet()
        applications
            .filter { it.package_name in updatableSet }
@@ -96,7 +96,7 @@ class PlayStoreOtherStoreStatusResolver @Inject constructor(
        }
    }

    private suspend fun resolveMissingOwnership(packageNames: List<String>) {
    private suspend fun resolveMissingPlayOwnership(packageNames: List<String>) {
        val needsResolution = packageNames.filter {
            val ownership = otherStoreOwnershipCache.classify(it)
            ownership != UpdateOwnership.PLAY_STORE_OWNED &&
@@ -105,24 +105,33 @@ class PlayStoreOtherStoreStatusResolver @Inject constructor(
        if (needsResolution.isEmpty()) return

        val playOwnedPackages = playStoreOwnershipVerifier.resolve(needsResolution)
        val skippedPackages = needsResolution - playOwnedPackages.toSet()
        otherStoreOwnershipCache.record(
            OtherStoreUpdateOwnershipResolution(
                playStorePackages = playOwnedPackages,
                skippedPackages = needsResolution - playOwnedPackages.toSet(),
                skippedPackages = skippedPackages,
            )
        )
    }

    private fun isPlayOtherStoreUpdateCandidate(application: Application): Boolean {
        return application.source == Source.PLAY_STORE &&
            !application.is_pwa &&
            application.package_name.isNotBlank() &&
            application.latest_version_code > 0 &&
            trustedStoreFor(application.package_name) == TrustedStore.NON_TRUSTED &&
            appLoungePackageManager.getPackageStatus(
    private fun isPlaySideloadUpdateCandidate(application: Application): Boolean {
        val hasPlayDetails = when {
            application.source != Source.PLAY_STORE -> false
            application.is_pwa -> false
            application.package_name.isBlank() -> false
            application.latest_version_code <= 0 -> false
            else -> true
        }
        if (!hasPlayDetails) {
            return false
        }

        val trustedStore = trustedStoreFor(application.package_name)
        val packageStatus = appLoungePackageManager.getPackageStatus(
            application.package_name,
            application.latest_version_code,
            ) == Status.UPDATABLE
        )
        return trustedStore == TrustedStore.SIDELOAD && packageStatus == Status.UPDATABLE
    }

    private fun applyCurrentDisplayStatus(application: Application) {
+10 −4
Original line number Diff line number Diff line
@@ -96,13 +96,14 @@ class SourceAwareStatusUpdater @Inject constructor(
        val trustedStore = trustedStoreFor(application.package_name)
        val thirdPartyUpdatesEnabled = trustedStore == TrustedStore.NON_TRUSTED &&
            appPreferencesRepository.shouldUpdateAppsFromOtherStores()
        val ownership = resolveOwnership(application, trustedStore, thirdPartyUpdatesEnabled)

        return getUpdateEligibilityUseCase.displayStatus(
            eligibilityRequest(
                application = application,
                rawStatus = rawStatus,
                trustedStore = trustedStore,
                ownership = resolveOwnership(application, trustedStore, thirdPartyUpdatesEnabled),
                ownership = ownership,
                thirdPartyUpdatesEnabled = thirdPartyUpdatesEnabled,
            ),
        )
@@ -113,10 +114,15 @@ class SourceAwareStatusUpdater @Inject constructor(
        trustedStore: TrustedStore,
        thirdPartyUpdatesEnabled: Boolean,
    ): UpdateOwnership? {
        if (trustedStore != TrustedStore.NON_TRUSTED || !thirdPartyUpdatesEnabled) {
            return null
        return when {
            trustedStore == TrustedStore.SIDELOAD -> {
                otherStoreOwnershipCache.classify(application.package_name) ?: UpdateOwnership.UNVERIFIED
            }
            trustedStore == TrustedStore.NON_TRUSTED && thirdPartyUpdatesEnabled -> {
                otherStoreOwnershipCache.classify(application.package_name) ?: UpdateOwnership.UNVERIFIED
            }
            else -> null
        }
        return otherStoreOwnershipCache.classify(application.package_name) ?: UpdateOwnership.UNVERIFIED
    }

    private fun trustedStoreFor(packageName: String): TrustedStore {
+19 −1
Original line number Diff line number Diff line
@@ -37,12 +37,30 @@ class OtherStoreUpdateOwnerResolver @Inject constructor(
    private val playStoreOwnershipVerifier: PlayStoreOtherStoreOwnershipVerifier,
) {
    suspend fun resolve(installedPackageNames: List<String>): OtherStoreUpdateOwnershipResolution {
        return resolveOwnership(installedPackageNames, includePlayStore = false)
    }

    suspend fun resolveSideload(
        installedPackageNames: List<String>,
        includePlayStore: Boolean,
    ): OtherStoreUpdateOwnershipResolution {
        return resolveOwnership(installedPackageNames, includePlayStore)
    }

    private suspend fun resolveOwnership(
        installedPackageNames: List<String>,
        includePlayStore: Boolean,
    ): OtherStoreUpdateOwnershipResolution {
        if (installedPackageNames.isEmpty()) {
            return OtherStoreUpdateOwnershipResolution()
        }

        val openSourceResolution = fdroidOwnershipVerifier.resolve(installedPackageNames)
        val playStorePackages = playStoreOwnershipVerifier.resolve(installedPackageNames)
        val playStorePackages = if (includePlayStore) {
            playStoreOwnershipVerifier.resolve(installedPackageNames)
        } else {
            emptyList()
        }
        val verifiedPackages = (openSourceResolution.openSourcePackages + playStorePackages).toSet()
        val skippedPackages = installedPackageNames.filterNot { it in verifiedPackages }

+5 −44
Original line number Diff line number Diff line
@@ -19,7 +19,6 @@
package foundation.e.apps.data.updates

import foundation.e.apps.OpenForTesting
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import foundation.e.apps.data.playstore.PlayStoreRepository
import foundation.e.apps.data.signing.SigningCertificateDigests
@@ -47,43 +46,18 @@ class PlayStoreOtherStoreOwnershipVerifier @Inject constructor(
        return packageNames.filter { packageName ->
            val installed = installedSigningDigestsByPackage[packageName] ?: SigningCertificateDigests()
            val play = playStoreSigningDigestsByPackage[packageName] ?: SigningCertificateDigests()
            verifyAndLog(packageName, installed, play)
            verify(installed, play)
        }
    }

    private fun verifyAndLog(
        packageName: String,
    private fun verify(
        installed: SigningCertificateDigests,
        play: SigningCertificateDigests,
    ): Boolean {
        Timber.i(
            "Comparing Play signing digests for %s: " +
                "installedSha256Count=%s, playSha256Count=%s, " +
                "installedDeliveryCount=%s, playDeliveryCount=%s",
            packageName,
            installed.sha256Digests.size,
            play.sha256Digests.size,
            installed.playStoreDeliveryHashes.size,
            play.playStoreDeliveryHashes.size,
        )

        return when {
            installed.isEmpty() -> {
                logSkipped(packageName, "installed signing certificate unavailable")
                false
            }
            play.isEmpty() -> {
                logSkipped(packageName, "no Play signing certificate metadata available")
                false
            }
            installed.matches(play) -> {
                logResolved(packageName, "matched Play signing certificate")
                true
            }
            else -> {
                logSkipped(packageName, "Play signing certificate mismatch")
                false
            }
            installed.isEmpty() -> false
            play.isEmpty() -> false
            else -> installed.matches(play)
        }
    }

@@ -103,17 +77,4 @@ class PlayStoreOtherStoreOwnershipVerifier @Inject constructor(
            emptyMap()
        }
    }

    private fun logResolved(packageName: String, reason: String) {
        Timber.i(
            "Other-store update owner for %s resolved to %s: %s",
            packageName,
            Source.PLAY_STORE,
            reason,
        )
    }

    private fun logSkipped(packageName: String, reason: String) {
        Timber.i("Other-store update owner for %s skipped: %s", packageName, reason)
    }
}
+15 −1
Original line number Diff line number Diff line
@@ -29,11 +29,14 @@ class TrustedStorePolicy @Inject constructor(
    @ApplicationContext private val context: Context,
) {
    fun classifyInstaller(installerPackageName: String): TrustedStore {
        return when (installerPackageName) {
        val normalizedInstallerPackageName = installerPackageName.trim()
        return when (normalizedInstallerPackageName) {
            "" -> TrustedStore.SIDELOAD
            context.packageName -> TrustedStore.APP_LOUNGE
            PACKAGE_NAME_ANDROID_VENDING -> TrustedStore.PLAY_STORE
            in openSourceInstallerPackages -> TrustedStore.OPEN_SOURCE
            in trustedNeutralInstallerPackages -> TrustedStore.TRUSTED_NEUTRAL
            in sideloadInstallerPackages -> TrustedStore.SIDELOAD
            else -> TrustedStore.NON_TRUSTED
        }
    }
@@ -54,6 +57,13 @@ class TrustedStorePolicy @Inject constructor(
    // accidentally treating them as Play or Open Source ownership.
    private val trustedNeutralInstallerPackages = emptySet<String>()

    private val sideloadInstallerPackages = setOf(
        PACKAGE_NAME_ANDROID_PACKAGE_INSTALLER,
        PACKAGE_NAME_GOOGLE_PACKAGE_INSTALLER,
        PACKAGE_NAME_ANDROID_DOCUMENTS_UI,
        PACKAGE_NAME_ANDROID_SHELL,
    )

    companion object {
        const val PACKAGE_NAME_F_DROID_BASIC = "org.fdroid.basic"
        const val PACKAGE_NAME_F_DROID = "org.fdroid.fdroid"
@@ -61,5 +71,9 @@ class TrustedStorePolicy @Inject constructor(
        const val PACKAGE_NAME_DROIDIFY = "com.looker.droidify"
        const val PACKAGE_NAME_NEO_STORE = "com.machiav3lli.fdroid"
        const val PACKAGE_NAME_ANDROID_VENDING = "com.android.vending"
        const val PACKAGE_NAME_ANDROID_PACKAGE_INSTALLER = "com.android.packageinstaller"
        const val PACKAGE_NAME_GOOGLE_PACKAGE_INSTALLER = "com.google.android.packageinstaller"
        const val PACKAGE_NAME_ANDROID_DOCUMENTS_UI = "com.android.documentsui"
        const val PACKAGE_NAME_ANDROID_SHELL = "com.android.shell"
    }
}
Loading