Loading app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingRepository.kt +4 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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>> { Loading @@ -40,7 +42,8 @@ class CleanApkSearchPagingRepository @Inject constructor( pagingSourceFactory = { CleanApkSearchPagingSource( cleanApkSearchHelper = cleanApkSearchHelper, params = params params = params, sourceAwareStatusUpdater = sourceAwareStatusUpdater, ) } ).flow Loading app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt +16 −2 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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? { Loading @@ -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 ) Loading @@ -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 } } app/src/main/java/foundation/e/apps/data/search/PlayStoreAppMapperImpl.kt +8 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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() } } } app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt +101 −14 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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( Loading @@ -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( Loading app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +3 −1 Original line number Diff line number Diff line Loading @@ -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 Loading
app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingRepository.kt +4 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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>> { Loading @@ -40,7 +42,8 @@ class CleanApkSearchPagingRepository @Inject constructor( pagingSourceFactory = { CleanApkSearchPagingSource( cleanApkSearchHelper = cleanApkSearchHelper, params = params params = params, sourceAwareStatusUpdater = sourceAwareStatusUpdater, ) } ).flow Loading
app/src/main/java/foundation/e/apps/data/search/CleanApkSearchPagingSource.kt +16 −2 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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? { Loading @@ -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 ) Loading @@ -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 } }
app/src/main/java/foundation/e/apps/data/search/PlayStoreAppMapperImpl.kt +8 −1 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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() } } }
app/src/main/java/foundation/e/apps/ui/compose/state/InstallStatusReconciler.kt +101 −14 Original line number Diff line number Diff line Loading @@ -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 Loading @@ -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( Loading @@ -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( Loading
app/src/main/java/foundation/e/apps/ui/search/v2/SearchFragmentV2.kt +3 −1 Original line number Diff line number Diff line Loading @@ -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