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

Commit 327d294b authored by Abhishek Aggarwal's avatar Abhishek Aggarwal Committed by Abhishek Aggarwal
Browse files

fix(install): avoid duplicate Play details fetch for resolved dependencies

parent 4290c87d
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -102,6 +102,12 @@ data class Application(
    @SerializedName(value = "antifeatures")
    val antiFeatures: List<Map<String, String>> = emptyList(),
    var isSystemApp: Boolean = false,

    /*
     * True when Play Store dependency metadata was loaded from a full details response.
     * An empty dependentLibraries list then means "no libraries", not "unknown".
     */
    val areDependentLibrariesResolved: Boolean = false,
    val dependentLibraries: List<SharedLib> = emptyList(),
) {
    val iconUrl: String?
+5 −1
Original line number Diff line number Diff line
@@ -28,7 +28,10 @@ import foundation.e.apps.data.application.data.Ratings
import foundation.e.apps.data.installation.model.SharedLib
import foundation.e.apps.data.application.data.Category as AppLoungeCategory

fun App.toApplication(context: Context): Application {
fun App.toApplication(
    context: Context,
    areDependentLibrariesResolved: Boolean = false,
): Application {
    val app = Application(
        _id = this.id.toString(),
        author = this.developerName,
@@ -58,6 +61,7 @@ fun App.toApplication(context: Context): Application {
        price = this.price,
        restriction = this.restriction,
        contentRating = this.contentRating,
        areDependentLibrariesResolved = areDependentLibrariesResolved,
        dependentLibraries = this.dependencies.dependentLibraries.map {
            SharedLib(packageName = it.packageName, versionCode = it.versionCode, offerType = it.offerType)
        }
+43 −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.install.core

import foundation.e.apps.data.enums.ResultStatus

sealed interface AppInstallEnqueueResult {
    data object Enqueued : AppInstallEnqueueResult

    data class Failed(
        val reason: AppInstallEnqueueFailureReason,
    ) : AppInstallEnqueueResult
}

sealed interface AppInstallEnqueueFailureReason {
    data class SharedLibraryDetailsUnavailable(
        val status: ResultStatus? = null,
        val cause: Throwable? = null,
    ) : AppInstallEnqueueFailureReason

    data class PlayStoreInstallMetadataUnavailable(
        val status: ResultStatus? = null,
        val cause: Throwable? = null,
    ) : AppInstallEnqueueFailureReason

    data object EnqueueRejected : AppInstallEnqueueFailureReason
}
+20 −91
Original line number Diff line number Diff line
@@ -19,15 +19,12 @@
package foundation.e.apps.data.install.core

import foundation.e.apps.data.application.AppManager
import foundation.e.apps.data.application.ApplicationRepository
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.installation.core.InstallationProcessor
import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.data.installation.model.InstallationResult
import foundation.e.apps.domain.model.install.Status
import timber.log.Timber
import javax.inject.Inject

class AppInstallationFacade @Inject constructor(
@@ -35,7 +32,7 @@ class AppInstallationFacade @Inject constructor(
    private val installationEnqueuer: InstallationEnqueuer,
    private val installationProcessor: InstallationProcessor,
    private val installationRequest: InstallationRequest,
    private val applicationRepository: ApplicationRepository,
    private val playInstallMetadataResolver: PlayInstallMetadataResolver,
) {
    /**
     * creates [AppInstall] from [Application] and enqueues into WorkManager to run install process.
@@ -48,74 +45,33 @@ class AppInstallationFacade @Inject constructor(
        isAnUpdate: Boolean = false
    ): Boolean {
        return when (val result = initAppInstallWithResult(application, isAnUpdate)) {
            is AppInstallStartResult.Started -> result.didEnqueue
            is AppInstallStartResult.Failed -> false
            AppInstallEnqueueResult.Enqueued -> true
            is AppInstallEnqueueResult.Failed -> false
        }
    }

    suspend fun initAppInstallWithResult(
        application: Application,
        isAnUpdate: Boolean = false
    ): AppInstallStartResult {
        val isUpdate = isAnUpdate || application.status == Status.UPDATABLE
        val appInstall = installationRequest.create(application, isUpdateRequest = isUpdate)

        if (application.source == Source.PLAY_STORE) {
            when (val sharedLibrariesResult = resolveSharedLibraries(application)) {
                is SharedLibrariesResult.Success -> {
                    appInstall.sharedLibs = sharedLibrariesResult.libraries
                }

                is SharedLibrariesResult.Failure -> {
                    return AppInstallStartResult.Failed(sharedLibrariesResult.reason)
                }
            }
        }

        return AppInstallStartResult.Started(
            enqueueAppForInstallation(appInstall, isUpdate, application.isSystemApp)
        )
    }

    private suspend fun resolveSharedLibraries(
        application: Application,
    ): SharedLibrariesResult {
        if (application.dependentLibraries.isNotEmpty()) {
            return SharedLibrariesResult.Success(application.dependentLibraries)
        }

        val packageName = application.package_name
        return runCatching {
            applicationRepository.getApplicationDetails(
                packageName,
                Source.PLAY_STORE
            )
        }.fold(
            onSuccess = { (details, status) ->
                if (status == ResultStatus.OK) {
                    SharedLibrariesResult.Success(details.dependentLibraries)
    ): AppInstallEnqueueResult {
        val playInstallMetadata = when (val result = playInstallMetadataResolver.resolve(application)) {
            is PlayInstallMetadataResult.Failure -> return AppInstallEnqueueResult.Failed(result.reason)
            is PlayInstallMetadataResult.Success -> result.metadata
        }
        val installApplication = playInstallMetadata.application

        val isUpdate = isAnUpdate ||
            application.status == Status.UPDATABLE ||
            installApplication.status == Status.UPDATABLE
        val appInstall = installationRequest.create(installApplication, isUpdateRequest = isUpdate)
        appInstall.sharedLibs = playInstallMetadata.sharedLibraries

        val didEnqueue = enqueueAppForInstallation(appInstall, isUpdate, installApplication.isSystemApp)
        return if (didEnqueue) {
            AppInstallEnqueueResult.Enqueued
        } else {
                    Timber.w(
                        "Cannot install %s: failed to fetch required shared library details (status=%s)",
                        packageName,
                        status,
                    )
                    SharedLibrariesResult.Failure(
                        AppInstallFailureReason.SharedLibraryDetailsUnavailable(status = status)
                    )
            AppInstallEnqueueResult.Failed(AppInstallEnqueueFailureReason.EnqueueRejected)
        }
            },
            onFailure = { throwable ->
                Timber.w(
                    throwable,
                    "Cannot install %s: failed to fetch required shared library details",
                    packageName,
                )
                SharedLibrariesResult.Failure(
                    AppInstallFailureReason.SharedLibraryDetailsUnavailable(cause = throwable)
                )
            },
        )
    }

    /**
@@ -159,30 +115,3 @@ class AppInstallationFacade @Inject constructor(
        appManager.cancelDownload(appInstall)
    }
}

private sealed interface SharedLibrariesResult {
    data class Success(
        val libraries: List<foundation.e.apps.data.installation.model.SharedLib>,
    ) : SharedLibrariesResult

    data class Failure(
        val reason: AppInstallFailureReason,
    ) : SharedLibrariesResult
}

sealed interface AppInstallStartResult {
    data class Started(
        val didEnqueue: Boolean,
    ) : AppInstallStartResult

    data class Failed(
        val reason: AppInstallFailureReason,
    ) : AppInstallStartResult
}

sealed interface AppInstallFailureReason {
    data class SharedLibraryDetailsUnavailable(
        val status: ResultStatus? = null,
        val cause: Throwable? = null,
    ) : AppInstallFailureReason
}
+174 −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.install.core

import foundation.e.apps.data.application.ApplicationRepository
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.enums.ResultStatus
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.installation.model.SharedLib
import foundation.e.apps.domain.model.install.Status
import kotlinx.coroutines.CancellationException
import timber.log.Timber
import javax.inject.Inject

class PlayInstallMetadataResolver @Inject constructor(
    private val applicationRepository: ApplicationRepository,
) {
    suspend fun resolve(application: Application): PlayInstallMetadataResult {
        val playDetails = when (val result = fetchRequiredPlayDetails(application)) {
            is MetadataStepResult.Failure -> return PlayInstallMetadataResult.Failure(result.reason)
            is MetadataStepResult.Success -> result.value
        }
        val installApplication = application.prepareInstallApplication(playDetails)
        val sharedLibraries = application.resolveSharedLibraries(playDetails)

        return PlayInstallMetadataResult.Success(
            PlayInstallMetadata(
                application = installApplication,
                sharedLibraries = sharedLibraries,
            )
        )
    }

    private suspend fun fetchRequiredPlayDetails(
        application: Application,
    ): MetadataStepResult<Application?> {
        if (application.source != Source.PLAY_STORE || !application.needsPlayDetails()) {
            return MetadataStepResult.Success(null)
        }

        val packageName = application.package_name
        return runCatching {
            applicationRepository.getApplicationDetails(packageName, Source.PLAY_STORE)
        }.fold(
            onSuccess = { (details, status) ->
                if (status != ResultStatus.OK) {
                    Timber.w(
                        "Cannot install %s: failed to fetch Play Store install details (status=%s)",
                        packageName,
                        status,
                    )
                    MetadataStepResult.Failure(
                        application.playDetailsFailureReason(status = status)
                    )
                } else {
                    MetadataStepResult.Success(details)
                }
            },
            onFailure = { throwable ->
                if (throwable is CancellationException) {
                    throw throwable
                }
                Timber.w(
                    throwable,
                    "Cannot install %s: failed to fetch Play Store install details",
                    packageName,
                )
                MetadataStepResult.Failure(
                    application.playDetailsFailureReason(cause = throwable)
                )
            },
        )
    }

    private fun Application.needsPlayDetails(): Boolean {
        return needsPlayStoreInstallMetadata() || needsSharedLibraryDetails()
    }

    private fun Application.needsPlayStoreInstallMetadata(): Boolean {
        return latest_version_code <= 0L || offer_type <= 0
    }

    private fun Application.needsSharedLibraryDetails(): Boolean {
        return dependentLibraries.isEmpty() && !areDependentLibrariesResolved
    }

    private fun Application.playDetailsFailureReason(
        status: ResultStatus? = null,
        cause: Throwable? = null,
    ): AppInstallEnqueueFailureReason {
        return if (needsPlayStoreInstallMetadata()) {
            AppInstallEnqueueFailureReason.PlayStoreInstallMetadataUnavailable(
                status = status,
                cause = cause,
            )
        } else {
            AppInstallEnqueueFailureReason.SharedLibraryDetailsUnavailable(
                status = status,
                cause = cause,
            )
        }
    }

    private fun Status.preserveUpdatableStatusFrom(originalStatus: Status): Status {
        return if (originalStatus == Status.UPDATABLE) {
            originalStatus
        } else {
            this
        }
    }

    private fun Application.prepareInstallApplication(playDetails: Application?): Application {
        if (source != Source.PLAY_STORE || playDetails == null) {
            return this
        }

        return playDetails.copy(
            isPurchased = isPurchased || playDetails.isPurchased,
            isSystemApp = isSystemApp || playDetails.isSystemApp,
            status = playDetails.status.preserveUpdatableStatusFrom(status),
            filterLevel = filterLevel,
        )
    }

    private fun Application.resolveSharedLibraries(playDetails: Application?): List<SharedLib> {
        return when {
            source != Source.PLAY_STORE -> emptyList()
            dependentLibraries.isNotEmpty() -> dependentLibraries
            areDependentLibrariesResolved -> emptyList()
            else -> playDetails?.dependentLibraries.orEmpty()
        }
    }
}

data class PlayInstallMetadata(
    val application: Application,
    val sharedLibraries: List<SharedLib>,
)

sealed interface PlayInstallMetadataResult {
    data class Success(
        val metadata: PlayInstallMetadata,
    ) : PlayInstallMetadataResult

    data class Failure(
        val reason: AppInstallEnqueueFailureReason,
    ) : PlayInstallMetadataResult
}

private sealed interface MetadataStepResult<out T> {
    data class Success<T>(
        val value: T,
    ) : MetadataStepResult<T>

    data class Failure(
        val reason: AppInstallEnqueueFailureReason,
    ) : MetadataStepResult<Nothing>
}
Loading