Loading app/src/main/java/foundation/e/apps/data/application/PlayStoreOtherStoreStatusResolver.kt +27 −18 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 } Loading @@ -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 && Loading @@ -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) { Loading app/src/main/java/foundation/e/apps/data/application/SourceAwareStatusUpdater.kt +10 −4 Original line number Diff line number Diff line Loading @@ -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, ), ) Loading @@ -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 { Loading app/src/main/java/foundation/e/apps/data/updates/OtherStoreUpdateOwnerResolver.kt +19 −1 Original line number Diff line number Diff line Loading @@ -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 } Loading app/src/main/java/foundation/e/apps/data/updates/PlayStoreOtherStoreOwnershipVerifier.kt +5 −44 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) } } Loading @@ -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) } } app/src/main/java/foundation/e/apps/data/updates/TrustedStorePolicy.kt +15 −1 Original line number Diff line number Diff line Loading @@ -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 } } Loading @@ -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" Loading @@ -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
app/src/main/java/foundation/e/apps/data/application/PlayStoreOtherStoreStatusResolver.kt +27 −18 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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 } Loading @@ -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 && Loading @@ -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) { Loading
app/src/main/java/foundation/e/apps/data/application/SourceAwareStatusUpdater.kt +10 −4 Original line number Diff line number Diff line Loading @@ -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, ), ) Loading @@ -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 { Loading
app/src/main/java/foundation/e/apps/data/updates/OtherStoreUpdateOwnerResolver.kt +19 −1 Original line number Diff line number Diff line Loading @@ -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 } Loading
app/src/main/java/foundation/e/apps/data/updates/PlayStoreOtherStoreOwnershipVerifier.kt +5 −44 Original line number Diff line number Diff line Loading @@ -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 Loading Loading @@ -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) } } Loading @@ -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) } }
app/src/main/java/foundation/e/apps/data/updates/TrustedStorePolicy.kt +15 −1 Original line number Diff line number Diff line Loading @@ -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 } } Loading @@ -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" Loading @@ -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" } }