Loading app/src/main/java/foundation/e/apps/data/application/data/Application.kt +7 −0 Original line number Diff line number Diff line Loading @@ -109,6 +109,13 @@ data class Application( */ val areDependentLibrariesResolved: Boolean = false, val dependentLibraries: List<SharedLib> = emptyList(), /* * 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. */ val cleanApkVersionCodeByDownloadVersion: Map<String, Long> = emptyMap(), ) { val iconUrl: String? get() { Loading app/src/main/java/foundation/e/apps/data/cleanapk/ApkSignatureManager.kt +24 −1 Original line number Diff line number Diff line Loading @@ -18,6 +18,7 @@ package foundation.e.apps.data.cleanapk import android.content.Context import android.util.Base64 import foundation.e.apps.data.install.FileManager import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.openpgp.PGPCompressedData Loading @@ -41,7 +42,7 @@ object ApkSignatureManager { try { return verifyAPKSignature( BufferedInputStream(FileInputStream(apkFilePath)), signature.byteInputStream(Charsets.UTF_8), signature.toPgpSignatureInputStream(), context.assets.open("f-droid.org-signing-key.gpg"), packageName ) Loading Loading @@ -108,4 +109,26 @@ object ApkSignatureManager { read = apkInputStream.read(buff) } } private fun String.toPgpSignatureInputStream(): InputStream { val normalized = trim() val bytes = if (normalized.startsWith(PGP_SIGNATURE_HEADER)) { normalized.toByteArray(Charsets.UTF_8) } else { Base64.decode(normalized.stripBase64Armor(), Base64.DEFAULT) } return bytes.inputStream() } private fun String.stripBase64Armor(): String { return lineSequence() .map { it.trim() } .filter { it.isNotEmpty() && !it.startsWith(PGP_ARMOR_CHECKSUM_PREFIX) } .joinToString(separator = "") .replace(PGP_ARMOR_CHECKSUM_SUFFIX, "") } private const val PGP_SIGNATURE_HEADER = "-----BEGIN PGP SIGNATURE-----" private const val PGP_ARMOR_CHECKSUM_PREFIX = "=" private val PGP_ARMOR_CHECKSUM_SUFFIX = Regex("=[A-Za-z0-9+/]{4}$") } app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +38 −0 Original line number Diff line number Diff line Loading @@ -52,6 +52,8 @@ import foundation.e.apps.data.playstore.utils.GPlayHttpClient import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.playstore.utils.gplayInternalExceptionHttpStatus import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.data.signing.SigningCertificateDigests import foundation.e.apps.data.signing.normalizeSigningDigest import foundation.e.apps.data.system.SystemInfoProvider import foundation.e.apps.domain.auth.AuthResult import foundation.e.apps.domain.auth.TokenRefreshHandler Loading Loading @@ -206,6 +208,42 @@ class PlayStoreRepository @Inject constructor( .map { it.toApplication(context) } } suspend fun getSigningCertificateDigests( packageNames: List<String>, ): Map<String, SigningCertificateDigests> = withContext(Dispatchers.IO) { if (packageNames.isEmpty()) { return@withContext emptyMap() } executeWithPlayAuthRecovery( operationName = "app signing certificate list", request = { getAppDetailsHelper().getAppByPackageName(packageNames) }, ).associate { app -> val digests = app.toSigningCertificateDigests() Timber.i( "Play signing digests for %s: sha256=%s, deliveryHashes=%s", app.packageName, digests.sha256Digests.size, digests.playStoreDeliveryHashes.size, ) app.packageName to digests } } private fun GplayApp.toSigningCertificateDigests(): SigningCertificateDigests { val sha256 = certificateSetList .map { normalizeSigningDigest(it.sha256) } .filterTo(linkedSetOf()) { it.isNotBlank() } val delivery = buildSet { certificateSetList.mapTo(this) { it.certificateSet.trim() } certificateHashList.mapTo(this) { it.trim() } }.filterTo(mutableSetOf()) { it.isNotBlank() } return SigningCertificateDigests( sha256Digests = sha256, playStoreDeliveryHashes = delivery, ) } override suspend fun getAppDetails(packageName: String): Application = withContext(Dispatchers.IO) { val appDetails = executeWithPlayAuthRecovery( Loading app/src/main/java/foundation/e/apps/data/updates/FdroidOtherStoreOwnershipVerifier.kt 0 → 100644 +216 −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.updates import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.OpenForTesting import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.cleanapk.ApkSignatureManager import foundation.e.apps.data.enums.Source import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.install.pkg.AppLoungePackageManager import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton data class FdroidOtherStoreOwnershipResolution( val openSourcePackages: List<String> = emptyList(), val openSourceApplications: List<Application> = emptyList(), ) @Singleton @OpenForTesting class FdroidOtherStoreOwnershipVerifier @Inject constructor( @ApplicationContext private val context: Context, private val appLoungePackageManager: AppLoungePackageManager, private val applicationRepository: ApplicationRepository, private val fDroidRepository: FDroidRepository, ) { suspend fun resolve(installedPackageNames: List<String>): FdroidOtherStoreOwnershipResolution { val openSourcePackages = mutableListOf<String>() val openSourceApplications = mutableListOf<Application>() val fDroidAppMetadata = getFDroidAppMetadata(installedPackageNames) installedPackageNames.forEach { packageName -> val metadata = fDroidAppMetadata[packageName] when { metadata == null -> { logUnresolved(packageName, "no F-Droid metadata/signature available") } metadata.signature.isEmpty() -> { logUnresolved(packageName, "F-Droid signature is empty") } matchesFdroidSignature(packageName, metadata.signature) -> { logResolved(packageName, "matched F-Droid signature") openSourcePackages.add(packageName) openSourceApplications.add(metadata.application) } else -> { logUnresolved(packageName, "F-Droid signature mismatch") } } } return FdroidOtherStoreOwnershipResolution( openSourcePackages = openSourcePackages, openSourceApplications = openSourceApplications, ) } private suspend fun getFDroidAppMetadata( installedPackageNames: List<String>, ): Map<String, FdroidAppMetadata> = coroutineScope { if (installedPackageNames.isEmpty()) { return@coroutineScope emptyMap() } val openSourceApps = runCatching { applicationRepository.getApplicationDetails( installedPackageNames, Source.OPEN_SOURCE, ).first }.getOrElse { exception -> if (exception is CancellationException) { throw exception } Timber.w(exception, "Failed to fetch F-Droid metadata for other-store apps") emptyList() } val signatureEntries = openSourceApps .filterNot { it.package_name.isBlank() } .map { app -> async { app.package_name to FdroidAppMetadata( application = app, signature = getPgpSignature(app), ) } } .awaitAll() signatureEntries.toMap() } private suspend fun getPgpSignature(cleanApkApplication: Application): String { val installedVersionSignature = calculateSignatureVersion(cleanApkApplication) if (installedVersionSignature.isBlank()) { Timber.i( "Skipping F-Droid signature lookup for %s: unable to resolve installed signature version", cleanApkApplication.package_name, ) return "" } val downloadInfoResult = handleNetworkResult { applicationRepository .getOSSDownloadInfo(cleanApkApplication._id, installedVersionSignature) .body()?.download_data } val pgpSignature = downloadInfoResult.data?.signature ?: "" Timber.i( "Signature calculated for: %s, signature version: %s, is sig blank: %s", cleanApkApplication.package_name, installedVersionSignature, pgpSignature.isBlank(), ) return pgpSignature } private suspend fun calculateSignatureVersion(latestCleanApkApp: Application): String { val packageName = latestCleanApkApp.package_name val latestSignatureVersion = latestCleanApkApp.latest_downloaded_version val installedVersionCode = appLoungePackageManager.getVersionCode(packageName) val installedVersionName = appLoungePackageManager.getVersionName(packageName) latestCleanApkApp.findCleanApkDownloadVersion(installedVersionCode)?.let { return it } val latestSignatureVersionNumber = latestSignatureVersion.substringAfter("_", "").toIntOrNull() val builds = handleNetworkResult { fDroidRepository.getBuildVersionInfo(packageName).asReversed() }.data val matchingIndex = builds?.indexOfFirst { it.versionCode == installedVersionCode && it.versionName == installedVersionName }?.takeIf { it >= 0 } return if (latestSignatureVersionNumber != null && matchingIndex != null) { "update_${latestSignatureVersionNumber - matchingIndex}" } else { "" } } private fun Application.findCleanApkDownloadVersion(installedVersionCode: String): String? { val installedVersionCodeLong = installedVersionCode.toLongOrNull() ?: return null return cleanApkVersionCodeByDownloadVersion.entries .firstOrNull { (_, versionCode) -> versionCode == installedVersionCodeLong } ?.key } private fun matchesFdroidSignature(packageName: String, signature: String): Boolean { val baseApkPath = appLoungePackageManager.getBaseApkPath(packageName) if (baseApkPath.isEmpty()) { logUnresolved(packageName, "installed base APK path missing") return false } return ApkSignatureManager.verifyFdroidSignature( context = context, apkFilePath = baseApkPath, signature = signature, packageName = packageName, ) } private fun logUnresolved(packageName: String, reason: String) { Timber.i( "Other-store update owner for %s unresolved after F-Droid check: %s", packageName, reason, ) } private fun logResolved(packageName: String, reason: String) { Timber.i( "Other-store update owner for %s resolved to %s: %s", packageName, Source.OPEN_SOURCE, reason, ) } } private data class FdroidAppMetadata( val application: Application, val signature: String, ) app/src/main/java/foundation/e/apps/data/updates/OtherStoreOwnershipCache.kt 0 → 100644 +68 −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.updates import foundation.e.apps.OpenForTesting import foundation.e.apps.domain.updates.UpdateOwnership import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton /** * Session-scoped cache populated by update refreshes and consulted synchronously by * browsing surfaces to keep non-trusted-store UPDATE buttons source-aware. * * Cache is invalidated via package install / uninstall broadcasts. */ @Singleton @OpenForTesting class OtherStoreOwnershipCache @Inject constructor() { private val classifications = ConcurrentHashMap<String, UpdateOwnership>() fun record(resolution: OtherStoreUpdateOwnershipResolution) { resolution.openSourcePackages.forEach { merge(it, UpdateOwnership.OPEN_SOURCE_OWNED) } resolution.playStorePackages.forEach { merge(it, UpdateOwnership.PLAY_STORE_OWNED) } resolution.skippedPackages.forEach { classifications.putIfAbsent(it, UpdateOwnership.UNVERIFIED) } } fun put(packageName: String, ownership: UpdateOwnership) { classifications[packageName] = ownership } fun classify(packageName: String): UpdateOwnership? { return classifications[packageName] } fun invalidate() { classifications.clear() } private fun merge(packageName: String, incoming: UpdateOwnership) { classifications.merge(packageName, incoming) { current, new -> when { current == new -> current current == UpdateOwnership.UNVERIFIED -> new new == UpdateOwnership.UNVERIFIED -> current else -> UpdateOwnership.MULTI_SOURCE_OWNED } } } } Loading
app/src/main/java/foundation/e/apps/data/application/data/Application.kt +7 −0 Original line number Diff line number Diff line Loading @@ -109,6 +109,13 @@ data class Application( */ val areDependentLibrariesResolved: Boolean = false, val dependentLibraries: List<SharedLib> = emptyList(), /* * 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. */ val cleanApkVersionCodeByDownloadVersion: Map<String, Long> = emptyMap(), ) { val iconUrl: String? get() { Loading
app/src/main/java/foundation/e/apps/data/cleanapk/ApkSignatureManager.kt +24 −1 Original line number Diff line number Diff line Loading @@ -18,6 +18,7 @@ package foundation.e.apps.data.cleanapk import android.content.Context import android.util.Base64 import foundation.e.apps.data.install.FileManager import org.bouncycastle.jce.provider.BouncyCastleProvider import org.bouncycastle.openpgp.PGPCompressedData Loading @@ -41,7 +42,7 @@ object ApkSignatureManager { try { return verifyAPKSignature( BufferedInputStream(FileInputStream(apkFilePath)), signature.byteInputStream(Charsets.UTF_8), signature.toPgpSignatureInputStream(), context.assets.open("f-droid.org-signing-key.gpg"), packageName ) Loading Loading @@ -108,4 +109,26 @@ object ApkSignatureManager { read = apkInputStream.read(buff) } } private fun String.toPgpSignatureInputStream(): InputStream { val normalized = trim() val bytes = if (normalized.startsWith(PGP_SIGNATURE_HEADER)) { normalized.toByteArray(Charsets.UTF_8) } else { Base64.decode(normalized.stripBase64Armor(), Base64.DEFAULT) } return bytes.inputStream() } private fun String.stripBase64Armor(): String { return lineSequence() .map { it.trim() } .filter { it.isNotEmpty() && !it.startsWith(PGP_ARMOR_CHECKSUM_PREFIX) } .joinToString(separator = "") .replace(PGP_ARMOR_CHECKSUM_SUFFIX, "") } private const val PGP_SIGNATURE_HEADER = "-----BEGIN PGP SIGNATURE-----" private const val PGP_ARMOR_CHECKSUM_PREFIX = "=" private val PGP_ARMOR_CHECKSUM_SUFFIX = Regex("=[A-Za-z0-9+/]{4}$") }
app/src/main/java/foundation/e/apps/data/playstore/PlayStoreRepository.kt +38 −0 Original line number Diff line number Diff line Loading @@ -52,6 +52,8 @@ import foundation.e.apps.data.playstore.utils.GPlayHttpClient import foundation.e.apps.data.playstore.utils.GplayHttpRequestException import foundation.e.apps.data.playstore.utils.gplayInternalExceptionHttpStatus import foundation.e.apps.data.preference.PlayStoreAuthStore import foundation.e.apps.data.signing.SigningCertificateDigests import foundation.e.apps.data.signing.normalizeSigningDigest import foundation.e.apps.data.system.SystemInfoProvider import foundation.e.apps.domain.auth.AuthResult import foundation.e.apps.domain.auth.TokenRefreshHandler Loading Loading @@ -206,6 +208,42 @@ class PlayStoreRepository @Inject constructor( .map { it.toApplication(context) } } suspend fun getSigningCertificateDigests( packageNames: List<String>, ): Map<String, SigningCertificateDigests> = withContext(Dispatchers.IO) { if (packageNames.isEmpty()) { return@withContext emptyMap() } executeWithPlayAuthRecovery( operationName = "app signing certificate list", request = { getAppDetailsHelper().getAppByPackageName(packageNames) }, ).associate { app -> val digests = app.toSigningCertificateDigests() Timber.i( "Play signing digests for %s: sha256=%s, deliveryHashes=%s", app.packageName, digests.sha256Digests.size, digests.playStoreDeliveryHashes.size, ) app.packageName to digests } } private fun GplayApp.toSigningCertificateDigests(): SigningCertificateDigests { val sha256 = certificateSetList .map { normalizeSigningDigest(it.sha256) } .filterTo(linkedSetOf()) { it.isNotBlank() } val delivery = buildSet { certificateSetList.mapTo(this) { it.certificateSet.trim() } certificateHashList.mapTo(this) { it.trim() } }.filterTo(mutableSetOf()) { it.isNotBlank() } return SigningCertificateDigests( sha256Digests = sha256, playStoreDeliveryHashes = delivery, ) } override suspend fun getAppDetails(packageName: String): Application = withContext(Dispatchers.IO) { val appDetails = executeWithPlayAuthRecovery( Loading
app/src/main/java/foundation/e/apps/data/updates/FdroidOtherStoreOwnershipVerifier.kt 0 → 100644 +216 −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.updates import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import foundation.e.apps.OpenForTesting import foundation.e.apps.data.application.ApplicationRepository import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.cleanapk.ApkSignatureManager import foundation.e.apps.data.enums.Source import foundation.e.apps.data.fdroid.FDroidRepository import foundation.e.apps.data.handleNetworkResult import foundation.e.apps.data.install.pkg.AppLoungePackageManager import kotlinx.coroutines.CancellationException import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope import timber.log.Timber import javax.inject.Inject import javax.inject.Singleton data class FdroidOtherStoreOwnershipResolution( val openSourcePackages: List<String> = emptyList(), val openSourceApplications: List<Application> = emptyList(), ) @Singleton @OpenForTesting class FdroidOtherStoreOwnershipVerifier @Inject constructor( @ApplicationContext private val context: Context, private val appLoungePackageManager: AppLoungePackageManager, private val applicationRepository: ApplicationRepository, private val fDroidRepository: FDroidRepository, ) { suspend fun resolve(installedPackageNames: List<String>): FdroidOtherStoreOwnershipResolution { val openSourcePackages = mutableListOf<String>() val openSourceApplications = mutableListOf<Application>() val fDroidAppMetadata = getFDroidAppMetadata(installedPackageNames) installedPackageNames.forEach { packageName -> val metadata = fDroidAppMetadata[packageName] when { metadata == null -> { logUnresolved(packageName, "no F-Droid metadata/signature available") } metadata.signature.isEmpty() -> { logUnresolved(packageName, "F-Droid signature is empty") } matchesFdroidSignature(packageName, metadata.signature) -> { logResolved(packageName, "matched F-Droid signature") openSourcePackages.add(packageName) openSourceApplications.add(metadata.application) } else -> { logUnresolved(packageName, "F-Droid signature mismatch") } } } return FdroidOtherStoreOwnershipResolution( openSourcePackages = openSourcePackages, openSourceApplications = openSourceApplications, ) } private suspend fun getFDroidAppMetadata( installedPackageNames: List<String>, ): Map<String, FdroidAppMetadata> = coroutineScope { if (installedPackageNames.isEmpty()) { return@coroutineScope emptyMap() } val openSourceApps = runCatching { applicationRepository.getApplicationDetails( installedPackageNames, Source.OPEN_SOURCE, ).first }.getOrElse { exception -> if (exception is CancellationException) { throw exception } Timber.w(exception, "Failed to fetch F-Droid metadata for other-store apps") emptyList() } val signatureEntries = openSourceApps .filterNot { it.package_name.isBlank() } .map { app -> async { app.package_name to FdroidAppMetadata( application = app, signature = getPgpSignature(app), ) } } .awaitAll() signatureEntries.toMap() } private suspend fun getPgpSignature(cleanApkApplication: Application): String { val installedVersionSignature = calculateSignatureVersion(cleanApkApplication) if (installedVersionSignature.isBlank()) { Timber.i( "Skipping F-Droid signature lookup for %s: unable to resolve installed signature version", cleanApkApplication.package_name, ) return "" } val downloadInfoResult = handleNetworkResult { applicationRepository .getOSSDownloadInfo(cleanApkApplication._id, installedVersionSignature) .body()?.download_data } val pgpSignature = downloadInfoResult.data?.signature ?: "" Timber.i( "Signature calculated for: %s, signature version: %s, is sig blank: %s", cleanApkApplication.package_name, installedVersionSignature, pgpSignature.isBlank(), ) return pgpSignature } private suspend fun calculateSignatureVersion(latestCleanApkApp: Application): String { val packageName = latestCleanApkApp.package_name val latestSignatureVersion = latestCleanApkApp.latest_downloaded_version val installedVersionCode = appLoungePackageManager.getVersionCode(packageName) val installedVersionName = appLoungePackageManager.getVersionName(packageName) latestCleanApkApp.findCleanApkDownloadVersion(installedVersionCode)?.let { return it } val latestSignatureVersionNumber = latestSignatureVersion.substringAfter("_", "").toIntOrNull() val builds = handleNetworkResult { fDroidRepository.getBuildVersionInfo(packageName).asReversed() }.data val matchingIndex = builds?.indexOfFirst { it.versionCode == installedVersionCode && it.versionName == installedVersionName }?.takeIf { it >= 0 } return if (latestSignatureVersionNumber != null && matchingIndex != null) { "update_${latestSignatureVersionNumber - matchingIndex}" } else { "" } } private fun Application.findCleanApkDownloadVersion(installedVersionCode: String): String? { val installedVersionCodeLong = installedVersionCode.toLongOrNull() ?: return null return cleanApkVersionCodeByDownloadVersion.entries .firstOrNull { (_, versionCode) -> versionCode == installedVersionCodeLong } ?.key } private fun matchesFdroidSignature(packageName: String, signature: String): Boolean { val baseApkPath = appLoungePackageManager.getBaseApkPath(packageName) if (baseApkPath.isEmpty()) { logUnresolved(packageName, "installed base APK path missing") return false } return ApkSignatureManager.verifyFdroidSignature( context = context, apkFilePath = baseApkPath, signature = signature, packageName = packageName, ) } private fun logUnresolved(packageName: String, reason: String) { Timber.i( "Other-store update owner for %s unresolved after F-Droid check: %s", packageName, reason, ) } private fun logResolved(packageName: String, reason: String) { Timber.i( "Other-store update owner for %s resolved to %s: %s", packageName, Source.OPEN_SOURCE, reason, ) } } private data class FdroidAppMetadata( val application: Application, val signature: String, )
app/src/main/java/foundation/e/apps/data/updates/OtherStoreOwnershipCache.kt 0 → 100644 +68 −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.updates import foundation.e.apps.OpenForTesting import foundation.e.apps.domain.updates.UpdateOwnership import java.util.concurrent.ConcurrentHashMap import javax.inject.Inject import javax.inject.Singleton /** * Session-scoped cache populated by update refreshes and consulted synchronously by * browsing surfaces to keep non-trusted-store UPDATE buttons source-aware. * * Cache is invalidated via package install / uninstall broadcasts. */ @Singleton @OpenForTesting class OtherStoreOwnershipCache @Inject constructor() { private val classifications = ConcurrentHashMap<String, UpdateOwnership>() fun record(resolution: OtherStoreUpdateOwnershipResolution) { resolution.openSourcePackages.forEach { merge(it, UpdateOwnership.OPEN_SOURCE_OWNED) } resolution.playStorePackages.forEach { merge(it, UpdateOwnership.PLAY_STORE_OWNED) } resolution.skippedPackages.forEach { classifications.putIfAbsent(it, UpdateOwnership.UNVERIFIED) } } fun put(packageName: String, ownership: UpdateOwnership) { classifications[packageName] = ownership } fun classify(packageName: String): UpdateOwnership? { return classifications[packageName] } fun invalidate() { classifications.clear() } private fun merge(packageName: String, incoming: UpdateOwnership) { classifications.merge(packageName, incoming) { current, new -> when { current == new -> current current == UpdateOwnership.UNVERIFIED -> new new == UpdateOwnership.UNVERIFIED -> current else -> UpdateOwnership.MULTI_SOURCE_OWNED } } } }