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

Commit e6b26376 authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

fix(playstore): pass installed cert hash for updates

parent ee958b26
Loading
Loading
Loading
Loading
Loading
+38 −8
Original line number Diff line number Diff line
@@ -23,10 +23,12 @@ import foundation.e.apps.data.cleanapk.CleanApkDownloadInfoFetcher
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.handleNetworkResult
import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.playstore.InstalledAppCertificateHashProvider
import javax.inject.Inject

class DownloadInfoApiImpl @Inject constructor(
    private val appSources: AppSourcesContainer
    private val appSources: AppSourcesContainer,
    private val installedAppCertificateHashProvider: InstalledAppCertificateHashProvider,
) : DownloadInfoApi {

    override suspend fun getOnDemandModule(
@@ -85,19 +87,19 @@ class DownloadInfoApiImpl @Inject constructor(
        appInstall: AppInstall,
        list: MutableList<String>
    ) {
        val downloadList =
            appSources.gplayRepo.getDownloadInfo(
                appInstall.packageName,
                appInstall.versionCode,
                appInstall.offerType
            )
        val certificateHash = getUpdateCertificateHash(appInstall)
        val downloadList = getMainAppDownloadInfo(appInstall, certificateHash)
        appInstall.files = downloadList
        list.addAll(downloadList.map { it.url })

        appInstall.sharedLibs.forEach { lib ->
            if (lib.downloadUrls.isEmpty()) {
                val libFiles = runCatching {
                    appSources.gplayRepo.getDownloadInfo(lib.packageName, lib.versionCode, lib.offerType)
                    appSources.gplayRepo.getDownloadInfo(
                        lib.packageName,
                        lib.versionCode,
                        lib.offerType,
                    )
                }.getOrElse {
                    error(
                        "Cannot install ${appInstall.packageName}: " +
@@ -115,6 +117,34 @@ class DownloadInfoApiImpl @Inject constructor(
        }
    }

    private suspend fun getMainAppDownloadInfo(
        appInstall: AppInstall,
        certificateHash: String?,
    ) = if (certificateHash.isNullOrBlank()) {
        appSources.gplayRepo.getDownloadInfo(
            appInstall.packageName,
            appInstall.versionCode,
            appInstall.offerType,
        )
    } else {
        appSources.gplayRepo.getDownloadInfo(
            appInstall.packageName,
            appInstall.versionCode,
            appInstall.offerType,
            certificateHash,
        )
    }

    private fun getUpdateCertificateHash(appInstall: AppInstall): String? {
        if (!appInstall.isUpdateRequest) {
            return null
        }

        return installedAppCertificateHashProvider.getLatestEncodedCertificateHash(
            appInstall.packageName,
        )
    }

    private suspend fun updateDownloadInfoFromCleanApk(
        appInstall: AppInstall,
        list: MutableList<String>
+93 −2
Original line number Diff line number Diff line
@@ -39,6 +39,9 @@ import foundation.e.apps.OpenForTesting
import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.installation.model.InstallationSource
import foundation.e.apps.data.installation.model.InstallationType
import foundation.e.apps.data.signing.SigningCertificateDigests
import foundation.e.apps.data.signing.toPlayStoreDeliveryCertificateHash
import foundation.e.apps.data.signing.toSha256SigningDigest
import foundation.e.apps.domain.model.install.Status
import timber.log.Timber
import java.io.File
@@ -141,8 +144,17 @@ class AppLoungePackageManager @Inject constructor(

    fun getInstallerName(packageName: String): String {
        return try {
            when {
                packageName == context.packageName -> context.packageName
                Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE -> {
                    val installerInfo = packageManager.getInstallSourceInfo(packageName)
            installerInfo.originatingPackageName ?: installerInfo.installingPackageName ?: UNKNOWN_VALUE
                    installerInfo.updateOwnerPackageName
                        ?: installerInfo.installingPackageName
                        ?: UNKNOWN_VALUE
                }
                else -> packageManager.getInstallSourceInfo(packageName).installingPackageName
                    ?: UNKNOWN_VALUE
            }
        } catch (e: NameNotFoundException) {
            Timber.e("getInstallerName -> $packageName : ${e.localizedMessage}")
            UNKNOWN_VALUE
@@ -170,6 +182,85 @@ class AppLoungePackageManager @Inject constructor(
        return packageInfo?.versionName ?: UNKNOWN_VALUE
    }

    fun getPlayStoreDeliveryCertificateHashes(packageName: String): Set<String> {
        return getSigningCertificateDigests(packageName).playStoreDeliveryHashes
    }

    fun getCurrentPlayStoreDeliveryCertificateHash(packageName: String): String? {
        val packageInfo = getPackageInfoWithSigningCertificates(packageName)
            ?: return null

        return getCurrentSigningCertificateBytes(packageInfo)
            .minByOrNull { it.toSha256SigningDigest() }
            ?.toPlayStoreDeliveryCertificateHash()
    }

    fun getSigningCertificateDigests(packageName: String): SigningCertificateDigests {
        val packageInfo = getPackageInfoWithSigningCertificates(packageName)
            ?: return SigningCertificateDigests()
        val signingCertificateBytes = getSigningCertificateBytes(packageInfo)

        return SigningCertificateDigests(
            sha256Digests = signingCertificateBytes
                .mapTo(linkedSetOf()) { it.toSha256SigningDigest() },
            playStoreDeliveryHashes = signingCertificateBytes
                .mapTo(linkedSetOf()) { it.toPlayStoreDeliveryCertificateHash() },
        )
    }

    private fun getCurrentSigningCertificateBytes(packageInfo: PackageInfo): List<ByteArray> {
        val signingInfo = packageInfo.signingInfo ?: return emptyList()
        val currentSigners = if (signingInfo.hasMultipleSigners()) {
            signingInfo.apkContentsSigners.orEmpty().toList()
        } else {
            signingInfo.signingCertificateHistory.orEmpty().takeLast(1)
        }

        return currentSigners.map { it.toByteArray() }
    }

    private fun getSigningCertificateBytes(packageInfo: PackageInfo): List<ByteArray> {
        val signingInfo = packageInfo.signingInfo ?: return emptyList()
        return if (signingInfo.hasMultipleSigners()) {
            signingInfo.apkContentsSigners
        } else {
            signingInfo.signingCertificateHistory
        }.orEmpty()
            .map { it.toByteArray() }
    }

    private fun getPackageInfoWithSigningCertificates(packageName: String): PackageInfo? {
        return try {
            when {
                Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> {
                    packageManager.getPackageInfo(
                        packageName,
                        PackageManager.PackageInfoFlags.of(
                            PackageManager.GET_SIGNING_CERTIFICATES.toLong(),
                        ),
                    )
                }
                else -> {
                    @Suppress("DEPRECATION")
                    packageManager.getPackageInfo(
                        packageName,
                        PackageManager.GET_SIGNING_CERTIFICATES,
                    )
                }
            }
        } catch (e: NameNotFoundException) {
            Timber.e(
                "getSigningCertificateDigests -> $packageName : ${e.localizedMessage}"
            )
            null
        } catch (e: IllegalArgumentException) {
            Timber.e(
                "getSigningCertificateDigests -> $packageName : ${e.localizedMessage}"
            )
            null
        }
    }

    /**
     * Installs the given package using system API.
     * When [sharedLibs] is non-empty, shared libraries are staged and committed sequentially
+38 −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.playstore

import foundation.e.apps.data.install.pkg.AppLoungePackageManager
import timber.log.Timber
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class InstalledAppCertificateHashProvider @Inject constructor(
    private val appLoungePackageManager: AppLoungePackageManager,
) {
    fun getLatestEncodedCertificateHash(packageName: String): String? {
        return runCatching {
            appLoungePackageManager
                .getCurrentPlayStoreDeliveryCertificateHash(packageName)
        }.onFailure {
            Timber.w(it, "Failed to get certificate hash for %s", packageName)
        }.getOrNull()
    }
}
+22 −3
Original line number Diff line number Diff line
@@ -458,7 +458,8 @@ class PlayStoreRepository @Inject constructor(
    suspend fun getDownloadInfo(
        idOrPackageName: String,
        versionCode: Long,
        offerType: Int
        offerType: Int,
        certificateHash: String? = null,
    ): List<PlayFile> = withContext(Dispatchers.IO) {
        var version = versionCode
        var offer = offerType
@@ -481,7 +482,20 @@ class PlayStoreRepository @Inject constructor(
                val purchaseHelper = PurchaseHelper(playStoreAuthManager.requireValidatedPlayStoreAuth())
                    .using(gPlayHttpClient)

                buildList { addAll(purchaseHelper.purchase(idOrPackageName, version, offer)) }
                buildList {
                    if (certificateHash.isNullOrBlank()) {
                        addAll(purchaseHelper.purchase(idOrPackageName, version, offer))
                    } else {
                        addAll(
                            purchaseHelper.purchase(
                                idOrPackageName,
                                version,
                                offer,
                                certificateHash,
                            )
                        )
                    }
                }
            },
        )
    }
@@ -498,7 +512,12 @@ class PlayStoreRepository @Inject constructor(
                request = {
                    val purchaseHelper = PurchaseHelper(playStoreAuthManager.requireValidatedPlayStoreAuth())
                        .using(gPlayHttpClient)
                    purchaseHelper.purchase(packageName, versionCode, offerType, moduleName)
                    purchaseHelper.purchase(
                        packageName = packageName,
                        versionCode = versionCode,
                        offerType = offerType,
                        splitModule = moduleName,
                    )
                },
            )
        }
+51 −1
Original line number Diff line number Diff line
@@ -25,11 +25,13 @@ import foundation.e.apps.data.cleanapk.repositories.CleanApkPwaRepository
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.installation.model.SharedLib
import foundation.e.apps.data.playstore.InstalledAppCertificateHashProvider
import foundation.e.apps.data.playstore.PlayStoreRepository
import io.mockk.coEvery
import io.mockk.coVerify
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.assertFailsWith
@@ -42,13 +44,15 @@ class DownloadInfoApiImplTest {
    private val gplayRepo = mockk<PlayStoreRepository>()
    private val cleanApkAppsRepo = mockk<CleanApkAppsRepository>()
    private val cleanApkPwaRepo = mockk<CleanApkPwaRepository>()
    private val installedAppCertificateHashProvider = mockk<InstalledAppCertificateHashProvider>()

    private lateinit var downloadInfoApi: DownloadInfoApiImpl

    @Before
    fun setUp() {
        downloadInfoApi = DownloadInfoApiImpl(
            AppSourcesContainer(gplayRepo, cleanApkAppsRepo, cleanApkPwaRepo)
            AppSourcesContainer(gplayRepo, cleanApkAppsRepo, cleanApkPwaRepo),
            installedAppCertificateHashProvider,
        )
    }

@@ -165,6 +169,52 @@ class DownloadInfoApiImplTest {
        assertThat(error.cause).isNull()
    }

    @Test
    fun updateFusedDownloadWithDownloadingInfo_usesInstalledCertificateHashForPlayUpdate() = runTest {
        val appInstall = AppInstall(
            packageName = "com.example.app",
            versionCode = 99L,
            offerType = 1,
            isUpdateRequest = true,
        )
        val mainFiles = listOf(playFile("https://example.org/app-base.apk"))

        every {
            installedAppCertificateHashProvider.getLatestEncodedCertificateHash("com.example.app")
        } returns "encoded-cert-hash"
        coEvery {
            gplayRepo.getDownloadInfo("com.example.app", 99L, 1, "encoded-cert-hash")
        } returns mainFiles

        downloadInfoApi.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall)

        assertThat(appInstall.downloadURLList).containsExactly("https://example.org/app-base.apk")
        coVerify(exactly = 1) {
            gplayRepo.getDownloadInfo("com.example.app", 99L, 1, "encoded-cert-hash")
        }
    }

    @Test
    fun updateFusedDownloadWithDownloadingInfo_skipsInstalledCertificateHashForPlayInstall() = runTest {
        val appInstall = AppInstall(
            packageName = "com.example.app",
            versionCode = 99L,
            offerType = 1,
            isUpdateRequest = false,
        )
        val mainFiles = listOf(playFile("https://example.org/app-base.apk"))

        coEvery { gplayRepo.getDownloadInfo("com.example.app", 99L, 1) } returns mainFiles

        downloadInfoApi.updateFusedDownloadWithDownloadingInfo(Source.PLAY_STORE, appInstall)

        assertThat(appInstall.downloadURLList).containsExactly("https://example.org/app-base.apk")
        verify(exactly = 0) {
            installedAppCertificateHashProvider.getLatestEncodedCertificateHash(any())
        }
        coVerify(exactly = 1) { gplayRepo.getDownloadInfo("com.example.app", 99L, 1) }
    }

    private fun playFile(url: String): PlayFile {
        val file = mockk<PlayFile>()
        every { file.url } returns url
Loading