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

Commit 336eb7a9 authored by Abhishek Aggarwal's avatar Abhishek Aggarwal
Browse files

Merge branch '0000-main-3-update_sign_issues' into 'main'

fix(search): preserve source-specific install status

See merge request !817
parents 3104b134 f85d4e6d
Loading
Loading
Loading
Loading
Loading
+4 −1
Original line number Diff line number Diff line
@@ -21,6 +21,7 @@ package foundation.e.apps.data.search
import androidx.paging.Pager
import androidx.paging.PagingConfig
import androidx.paging.PagingData
import foundation.e.apps.data.application.SourceAwareStatusUpdater
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.cleanapk.CleanApkSearchHelper
import kotlinx.coroutines.flow.Flow
@@ -28,6 +29,7 @@ import javax.inject.Inject

class CleanApkSearchPagingRepository @Inject constructor(
    private val cleanApkSearchHelper: CleanApkSearchHelper,
    private val sourceAwareStatusUpdater: SourceAwareStatusUpdater,
) : SearchPagingRepository {

    override fun cleanApkSearch(params: CleanApkSearchParams): Flow<PagingData<Application>> {
@@ -40,7 +42,8 @@ class CleanApkSearchPagingRepository @Inject constructor(
            pagingSourceFactory = {
                CleanApkSearchPagingSource(
                    cleanApkSearchHelper = cleanApkSearchHelper,
                    params = params
                    params = params,
                    sourceAwareStatusUpdater = sourceAwareStatusUpdater,
                )
            }
        ).flow
+16 −2
Original line number Diff line number Diff line
@@ -20,8 +20,11 @@ package foundation.e.apps.data.search

import androidx.paging.PagingSource
import androidx.paging.PagingState
import foundation.e.apps.data.application.SourceAwareStatusUpdater
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.cleanapk.CleanApkRetrofit
import foundation.e.apps.data.cleanapk.CleanApkSearchHelper
import foundation.e.apps.data.enums.Source
import retrofit2.HttpException
import java.io.IOException

@@ -30,6 +33,7 @@ private const val INITIAL_PAGE = 1
class CleanApkSearchPagingSource(
    private val cleanApkSearchHelper: CleanApkSearchHelper,
    private val params: CleanApkSearchParams,
    private val sourceAwareStatusUpdater: SourceAwareStatusUpdater,
) : PagingSource<Int, Application>() {

    override fun getRefreshKey(state: PagingState<Int, Application>): Int? {
@@ -56,12 +60,11 @@ class CleanApkSearchPagingSource(
            )

            val totalPages = response.numberOfPages

            val nextKey = if (page < totalPages) page + 1 else null
            val prevKey = if (page > INITIAL_PAGE) page - 1 else null

            LoadResult.Page(
                data = response.apps,
                data = response.apps.map { it.prepareForDisplay() },
                prevKey = prevKey,
                nextKey = nextKey
            )
@@ -73,4 +76,15 @@ class CleanApkSearchPagingSource(
            LoadResult.Error(exception)
        }
    }

    private fun Application.prepareForDisplay(): Application {
        source = if (params.appType == CleanApkRetrofit.APP_TYPE_PWA || is_pwa) {
            Source.PWA
        } else {
            Source.OPEN_SOURCE
        }
        sourceAwareStatusUpdater.applyDisplayStatus(this)
        updateType()
        return this
    }
}
+8 −1
Original line number Diff line number Diff line
@@ -21,8 +21,10 @@ package foundation.e.apps.data.search
import android.content.Context
import com.aurora.gplayapi.data.models.App
import dagger.hilt.android.qualifiers.ApplicationContext
import foundation.e.apps.data.application.SourceAwareStatusUpdater
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.application.utils.toApplication
import foundation.e.apps.data.enums.Source
import foundation.e.apps.domain.search.PlayStoreAppMapper
import javax.inject.Inject
import javax.inject.Singleton
@@ -30,8 +32,13 @@ import javax.inject.Singleton
@Singleton
class PlayStoreAppMapperImpl @Inject constructor(
    @ApplicationContext private val context: Context,
    private val sourceAwareStatusUpdater: SourceAwareStatusUpdater,
) : PlayStoreAppMapper {
    override fun map(app: App): Application {
        return app.toApplication(context)
        return app.toApplication(context).apply {
            source = Source.PLAY_STORE
            sourceAwareStatusUpdater.updateStatus(this)
            updateType()
        }
    }
}
+101 −14
Original line number Diff line number Diff line
@@ -19,10 +19,16 @@
package foundation.e.apps.ui.compose.state

import foundation.e.apps.data.application.ApplicationRepository
import foundation.e.apps.data.application.PlayStoreOtherStoreStatusResolver
import foundation.e.apps.data.application.data.Application
import foundation.e.apps.data.enums.Source
import foundation.e.apps.data.install.DownloadProgressTracker
import foundation.e.apps.data.install.displayStatusOr
import foundation.e.apps.data.install.download.data.DownloadProgress
import foundation.e.apps.data.install.matches
import foundation.e.apps.data.install.toInstallationSource
import foundation.e.apps.data.installation.model.AppInstall
import foundation.e.apps.domain.model.install.Status
import javax.inject.Inject
import javax.inject.Singleton

@@ -32,12 +38,14 @@ import javax.inject.Singleton
 * Responsibilities:
 * - prefer active downloads over package/PWA status
 * - compute progress percent when download is active
 * - fall back to getFusedAppInstallationStatus for installed/updatable app detection
 * - fall back to source-aware install status, hydrating Play-other-store apps and probing
 *   open-source detail when local status is INSTALLED
 */
@Singleton
class InstallStatusReconciler @Inject constructor(
    private val applicationRepository: ApplicationRepository,
    private val downloadProgressTracker: DownloadProgressTracker,
    private val playStoreOtherStoreStatusResolver: PlayStoreOtherStoreStatusResolver,
) {

    data class Result(
@@ -45,30 +53,109 @@ class InstallStatusReconciler @Inject constructor(
        val progressPercent: Int? = null,
    )

    private data class FallbackStatus(
        val status: Status,
        val updateDetails: Application? = null,
    )

    suspend fun reconcile(
        app: Application,
        snapshot: StatusSnapshot,
        progress: DownloadProgress? = null,
    ): Result {
        // Prefer matching active download
        val activeDownload = snapshot.downloads.find { matches(app, it) }
        if (activeDownload != null) {
            val progressPercent = progressPercent(activeDownload, progress)
            app.status = activeDownload.status
        return if (activeDownload == null) {
            reconcileWithoutDownload(app)
        } else {
            reconcileWithDownload(app, activeDownload, progress)
        }
    }

    private suspend fun reconcileWithDownload(
        app: Application,
        download: AppInstall,
        progress: DownloadProgress?,
    ): Result {
        // displayStatusOr only consults the fallback when the download is a stale
        // INSTALLATION_ISSUE; avoid computing it for healthy in-flight downloads.
        val resolvedStatus = if (download.status == Status.INSTALLATION_ISSUE) {
            download.displayStatusOr(getFallbackStatus(app).status)
        } else {
            download.status
        }
        app.status = resolvedStatus
        // If we substituted the download status with a fallback, suppress progress:
        // it belonged to the stale download record.
        val progressPercent = if (resolvedStatus == download.status) {
            progressPercent(download, progress)
        } else {
            null
        }
        return Result(app, progressPercent)
    }

        // No active download -> rely on local install status (handles native + PWA)
        app.status = applicationRepository.getFusedAppInstallationStatus(app)
        return Result(app, null)
    private suspend fun reconcileWithoutDownload(app: Application): Result {
        val fallback = getFallbackStatus(app)
        fallback.updateDetails?.let { return Result(it) }
        app.status = fallback.status
        return Result(app)
    }

    private suspend fun getFallbackStatus(app: Application): FallbackStatus {
        // Play apps installed via a non-trusted installer need the verifier to decide whether
        // an UPDATABLE button should fire on the Play row.
        resolvePlayHydratedDetails(app)?.let { return FallbackStatus(Status.UPDATABLE, it) }

        val localStatus = applicationRepository.getFusedAppInstallationStatus(app)
        return when {
            // Source-aware search rows may carry an UPDATABLE status that the local package
            // fallback can't see (different source than the installed APK). Preserve it.
            app.status == Status.UPDATABLE && localStatus == Status.INSTALLED ->
                FallbackStatus(app.status)
            // Search results carry lightweight metadata; probe full open-source detail to find
            // out whether the installed APK has a newer version available.
            localStatus == Status.INSTALLED ->
                openSourceDetailFallback(app, localStatus)
            else -> FallbackStatus(localStatus)
        }
    }

    private suspend fun resolvePlayHydratedDetails(app: Application): Application? {
        if (app.source != Source.PLAY_STORE || app.package_name.isBlank()) return null

        val resolution = playStoreOtherStoreStatusResolver.resolveAndApply(
            applications = listOf(app),
            hydrateMissingDetails = true,
        )
        return resolution.hydratedDetailsByPackage[app.package_name]
            ?.takeIf { app.status == Status.UPDATABLE }
            ?.copy(status = Status.UPDATABLE)
    }

    private suspend fun openSourceDetailFallback(
        app: Application,
        localStatus: Status,
    ): FallbackStatus {
        val updateDetails = fetchUpdatableOpenSourceDetails(app)
        return if (updateDetails != null) {
            FallbackStatus(Status.UPDATABLE, updateDetails)
        } else {
            FallbackStatus(localStatus)
        }
    }

    private suspend fun fetchUpdatableOpenSourceDetails(app: Application): Application? {
        if (app.source != Source.OPEN_SOURCE || app.package_name.isBlank()) return null
        val (details, _) = applicationRepository.getApplicationDetails(app.package_name, app.source)
        return details.takeIf { it.package_name.isNotBlank() && it.status == Status.UPDATABLE }
    }

    private fun matches(app: Application, install: AppInstall): Boolean {
        val pkg = app.package_name
        val id = app._id
        return install.packageName == pkg ||
            install.id == id ||
            install.id == pkg
        return install.matches(
            source = app.source.toInstallationSource(),
            packageName = app.package_name,
            id = app._id,
        )
    }

    private suspend fun progressPercent(
+3 −1
Original line number Diff line number Diff line
@@ -194,7 +194,9 @@ class SearchFragmentV2 : Fragment(R.layout.fragment_search_v2) {
    }

    private fun progressKeyFor(app: Application): String {
        return app.package_name.takeIf { it.isNotBlank() } ?: app._id
        // Key by source so an OSS row and a Play row for the same package don't share progress.
        val appKey = app.package_name.takeIf { it.isNotBlank() } ?: app._id
        return "${app.source.name}:$appKey"
    }

    private fun purchaseStateFor(app: Application): PurchaseState {
Loading