From f83c892a1967077dddb6ae838d182c68e1c0175c Mon Sep 17 00:00:00 2001 From: TheScarastic Date: Fri, 3 Apr 2026 00:31:57 +0530 Subject: [PATCH] feat(update): Add support for updating apps installed via 3rd party app store --- .../e/apps/data/application/UpdatesDao.kt | 17 +- .../apps/data/install/pkg/InstallerService.kt | 24 +++ .../InstalledAppUpdateInfoProviderImpl.kt | 50 +++++ .../e/apps/data/updates/UpdatesManagerImpl.kt | 34 ++- .../data/updates/UpdatesManagerRepository.kt | 15 +- .../e/apps/data/updates/UpdatesStateStore.kt | 75 +++++++ .../GetOtherStoreUpdateConfirmationUseCase.kt | 74 +++++++ .../install/InstalledAppUpdateInfoProvider.kt | 29 +++ .../java/foundation/e/apps/ui/MainActivity.kt | 42 ++++ .../e/apps/ui/MainActivityViewModel.kt | 48 ++++- app/src/main/res/values-de/strings.xml | 2 + app/src/main/res/values-es/strings.xml | 2 + app/src/main/res/values-fi/strings.xml | 2 + app/src/main/res/values-fr/strings.xml | 2 + app/src/main/res/values-is/strings.xml | 2 + app/src/main/res/values-it/strings.xml | 2 + app/src/main/res/values-ja/strings.xml | 2 + app/src/main/res/values-nb-rNO/strings.xml | 2 + app/src/main/res/values-nl/strings.xml | 2 + app/src/main/res/values-pt-rBR/strings.xml | 2 + app/src/main/res/values-ru/strings.xml | 2 + app/src/main/res/values-sk/strings.xml | 2 + app/src/main/res/values-sv/strings.xml | 2 + app/src/main/res/values-tr/strings.xml | 2 + app/src/main/res/values-uk/strings.xml | 2 + app/src/main/res/values/strings.xml | 2 + .../e/apps/FakeAppLoungePackageManager.kt | 16 +- .../e/apps/UpdateManagerImptTest.kt | 80 +++++++ .../mapper/ApplicationDomainMapperTest.kt | 2 +- ...OtherStoreUpdateConfirmationUseCaseTest.kt | 151 ++++++++++++++ .../e/apps/ui/MainActivityViewModelTest.kt | 196 ++++++++++++++++++ .../e/apps/ui/updates/UpdatesViewModelTest.kt | 37 +++- 32 files changed, 897 insertions(+), 25 deletions(-) create mode 100644 app/src/main/java/foundation/e/apps/data/updates/InstalledAppUpdateInfoProviderImpl.kt create mode 100644 app/src/main/java/foundation/e/apps/data/updates/UpdatesStateStore.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/install/GetOtherStoreUpdateConfirmationUseCase.kt create mode 100644 app/src/main/java/foundation/e/apps/domain/install/InstalledAppUpdateInfoProvider.kt create mode 100644 app/src/test/java/foundation/e/apps/domain/install/GetOtherStoreUpdateConfirmationUseCaseTest.kt create mode 100644 app/src/test/java/foundation/e/apps/ui/MainActivityViewModelTest.kt diff --git a/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt b/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt index e99c0cd03..ea5029841 100644 --- a/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt +++ b/app/src/main/java/foundation/e/apps/data/application/UpdatesDao.kt @@ -23,18 +23,31 @@ import foundation.e.apps.data.installation.model.AppInstall object UpdatesDao { private val _appsAwaitingForUpdate: MutableList = mutableListOf() val appsAwaitingForUpdate: List = _appsAwaitingForUpdate + var appsAwaitingForUpdateIncludesOtherStores: Boolean = false + private set private val _successfulUpdatedApps = mutableListOf() val successfulUpdatedApps: List = _successfulUpdatedApps - fun addItemsForUpdate(appsNeedUpdate: List) { + fun addItemsForUpdate( + appsNeedUpdate: List, + includesOtherStores: Boolean = false + ) { _appsAwaitingForUpdate.clear() _appsAwaitingForUpdate.addAll(appsNeedUpdate) + appsAwaitingForUpdateIncludesOtherStores = + appsNeedUpdate.isNotEmpty() && includesOtherStores } fun hasAnyAppsForUpdate() = _appsAwaitingForUpdate.isNotEmpty() - fun removeUpdateIfExists(packageName: String) = _appsAwaitingForUpdate.removeIf { it.package_name == packageName } + fun removeUpdateIfExists(packageName: String): Boolean { + val removed = _appsAwaitingForUpdate.removeIf { it.package_name == packageName } + if (_appsAwaitingForUpdate.isEmpty()) { + appsAwaitingForUpdateIncludesOtherStores = false + } + return removed + } fun addSuccessfullyUpdatedApp(appInstall: AppInstall) { _successfulUpdatedApps.add(appInstall) diff --git a/app/src/main/java/foundation/e/apps/data/install/pkg/InstallerService.kt b/app/src/main/java/foundation/e/apps/data/install/pkg/InstallerService.kt index 9b0ca6ae8..02aab6896 100644 --- a/app/src/main/java/foundation/e/apps/data/install/pkg/InstallerService.kt +++ b/app/src/main/java/foundation/e/apps/data/install/pkg/InstallerService.kt @@ -21,6 +21,7 @@ package foundation.e.apps.data.install.pkg import android.app.Service import android.content.Intent import android.content.pm.PackageInstaller +import android.os.Build import android.os.IBinder import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.data.application.AppManager @@ -59,6 +60,13 @@ class InstallerService : Service() { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, DEFAULT_INSTALL_STATUS) + + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + launchUserConfirmation(intent) + stopSelf() + return START_NOT_STICKY + } + var packageName = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) val extra = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) @@ -75,6 +83,22 @@ class InstallerService : Service() { return START_NOT_STICKY } + @Suppress("DEPRECATION") + private fun launchUserConfirmation(intent: Intent) { + val confirmIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + intent.getParcelableExtra(Intent.EXTRA_INTENT, Intent::class.java) + } else { + intent.getParcelableExtra(Intent.EXTRA_INTENT) + } + if (confirmIntent != null) { + confirmIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + startActivity(confirmIntent) + Timber.d("Launched user confirmation dialog for install") + } else { + Timber.e("STATUS_PENDING_USER_ACTION but no confirmation intent found") + } + } + private fun postStatus(status: Int, packageName: String?, extra: String?) { Timber.d("postStatus: $status $packageName $extra") if (status == PackageInstaller.STATUS_SUCCESS) { diff --git a/app/src/main/java/foundation/e/apps/data/updates/InstalledAppUpdateInfoProviderImpl.kt b/app/src/main/java/foundation/e/apps/data/updates/InstalledAppUpdateInfoProviderImpl.kt new file mode 100644 index 000000000..f55e58f28 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/updates/InstalledAppUpdateInfoProviderImpl.kt @@ -0,0 +1,50 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.data.updates + +import foundation.e.apps.data.install.pkg.AppLoungePackageManager +import foundation.e.apps.domain.install.InstalledAppUpdateInfoProvider +import foundation.e.apps.domain.model.install.Status +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class InstalledAppUpdateInfoProviderImpl @Inject constructor( + private val appLoungePackageManager: AppLoungePackageManager, + private val updatesManagerImpl: UpdatesManagerImpl, +) : InstalledAppUpdateInfoProvider { + + override fun getPackageStatus(packageName: String, versionCode: Long): Status { + return appLoungePackageManager.getPackageStatus(packageName, versionCode) + } + + override fun isInstalledFromOtherStore(packageName: String): Boolean { + return updatesManagerImpl.isAppInstalledFromOtherStore(packageName) + } + + override fun getInstallerLabel(packageName: String): String? { + val installerPackageName = appLoungePackageManager.getInstallerName(packageName) + .takeIf { it.isNotBlank() } + ?: return null + + return runCatching { + appLoungePackageManager.getAppNameFromPackageName(installerPackageName) + }.getOrDefault(installerPackageName).ifBlank { installerPackageName } + } +} diff --git a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt index e4fbbdbc0..42de6f35f 100644 --- a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt +++ b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerImpl.kt @@ -68,12 +68,16 @@ class UpdatesManagerImpl @Inject constructor( if (appPreferencesRepository.shouldUpdateAppsFromOtherStores()) { withContext(Dispatchers.IO) { - val otherStoresInstalledApps = getAppsFromOtherStores().toMutableList() + val fdroidInstalledApps = getFDroidInstalledApps() + openSourceInstalledApps.addAll(fdroidInstalledApps) + + val otherStoresInstalledApps = getAppsFromOtherStores() + .filterNot { it in fdroidInstalledApps } + .toMutableList() // This list is based on app signatures val updatableFDroidApps = findPackagesMatchingFDroidSignatures(otherStoresInstalledApps) - openSourceInstalledApps.addAll(updatableFDroidApps) otherStoresInstalledApps.removeAll(updatableFDroidApps) @@ -130,7 +134,12 @@ class UpdatesManagerImpl @Inject constructor( val openSourceInstalledApps = getOpenSourceInstalledApps().toMutableList() if (appPreferencesRepository.shouldUpdateAppsFromOtherStores()) { - val otherStoresInstalledApps = getAppsFromOtherStores().toMutableList() + val fdroidInstalledApps = getFDroidInstalledApps() + openSourceInstalledApps.addAll(fdroidInstalledApps) + + val otherStoresInstalledApps = getAppsFromOtherStores() + .filterNot { it in fdroidInstalledApps } + .toMutableList() // This list is based on app signatures val updatableFDroidApps = @@ -195,13 +204,21 @@ class UpdatesManagerImpl @Inject constructor( /** * Lists apps directly updatable by App Lounge from the Open Source category. - * (This includes apps installed by F-Droid client app, if used by the user; - * F-Droid is not considered a third party source.) + * + * Apps installed by external clients such as F-Droid are handled through the + * "other stores" flow so the user preference can enable or disable them. */ private fun getOpenSourceInstalledApps(): List { return userApplications.filter { appLoungePackageManager.getInstallerName(it.packageName) in listOf( context.packageName, + ) + }.map { it.packageName } + } + + private fun getFDroidInstalledApps(): List { + return userApplications.filter { + appLoungePackageManager.getInstallerName(it.packageName) in listOf( PACKAGE_NAME_F_DROID, PACKAGE_NAME_F_DROID_PRIVILEGED, ) @@ -224,10 +241,9 @@ class UpdatesManagerImpl @Inject constructor( /** * Lists apps installed from other app stores. - * (F-Droid client is not considered a third party source.) * * @return List of package names of apps installed from other app stores like - * Aurora Store, Apk mirror, apps installed from browser, apps from ADB etc. + * F-Droid, Aurora Store, ApkMirror, browser downloads, apps from ADB, etc. */ private fun getAppsFromOtherStores(): List { val gplayAndOpenSourceInstalledApps = getGPlayInstalledApps() + getOpenSourceInstalledApps() @@ -236,6 +252,10 @@ class UpdatesManagerImpl @Inject constructor( }.map { it.packageName } } + fun isAppInstalledFromOtherStore(packageName: String): Boolean { + return getAppsFromOtherStores().contains(packageName) + } + /** * Runs API (GPlay api or CleanApk) and accumulates the updatable apps * into a provided list. diff --git a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerRepository.kt b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerRepository.kt index 202f39518..ba8e51d01 100644 --- a/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerRepository.kt +++ b/app/src/main/java/foundation/e/apps/data/updates/UpdatesManagerRepository.kt @@ -22,20 +22,29 @@ import foundation.e.apps.data.application.UpdatesDao 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.domain.preferences.AppPreferencesRepository import javax.inject.Inject class UpdatesManagerRepository @Inject constructor( - private val updatesManagerImpl: UpdatesManagerImpl + private val updatesManagerImpl: UpdatesManagerImpl, + private val appPreferencesRepository: AppPreferencesRepository, ) { + fun isAppInstalledFromOtherStore(packageName: String): Boolean { + return updatesManagerImpl.isAppInstalledFromOtherStore(packageName) + } suspend fun getUpdates(): Pair, ResultStatus> { - if (UpdatesDao.hasAnyAppsForUpdate()) { + val includesOtherStores = appPreferencesRepository.shouldUpdateAppsFromOtherStores() + if ( + UpdatesDao.hasAnyAppsForUpdate() && + UpdatesDao.appsAwaitingForUpdateIncludesOtherStores == includesOtherStores + ) { return Pair(UpdatesDao.appsAwaitingForUpdate, ResultStatus.OK) } return updatesManagerImpl.getUpdates().run { val filteredApps = first.filter { !(!it.isFree && it.source == Source.PLAY_STORE && !it.isPurchased) } - UpdatesDao.addItemsForUpdate(filteredApps) + UpdatesDao.addItemsForUpdate(filteredApps, includesOtherStores) Pair(filteredApps, this.second) } } diff --git a/app/src/main/java/foundation/e/apps/data/updates/UpdatesStateStore.kt b/app/src/main/java/foundation/e/apps/data/updates/UpdatesStateStore.kt new file mode 100644 index 000000000..7e8588432 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/data/updates/UpdatesStateStore.kt @@ -0,0 +1,75 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.data.updates + +import foundation.e.apps.data.application.data.Application +import foundation.e.apps.data.installation.model.AppInstall +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class UpdatesStateStore @Inject constructor() { + private val lock = Any() + private val awaitingUpdates = mutableListOf() + private var awaitingUpdatesIncludeOtherStores = false + private val successfulUpdatedApps = mutableListOf() + + fun getCachedAwaitingUpdates(includesOtherStores: Boolean): List? = synchronized(lock) { + awaitingUpdates + .takeIf { it.isNotEmpty() && awaitingUpdatesIncludeOtherStores == includesOtherStores } + ?.toList() + } + + fun cacheAwaitingUpdates( + apps: List, + includesOtherStores: Boolean = false + ) = synchronized(lock) { + awaitingUpdates.clear() + awaitingUpdates.addAll(apps) + awaitingUpdatesIncludeOtherStores = apps.isNotEmpty() && includesOtherStores + } + + fun clearAwaitingUpdates() { + cacheAwaitingUpdates(emptyList()) + } + + fun removeCachedAwaitingUpdate(packageName: String): Boolean = synchronized(lock) { + val removed = awaitingUpdates.removeIf { it.package_name == packageName } + if (awaitingUpdates.isEmpty()) { + awaitingUpdatesIncludeOtherStores = false + } + removed + } + + fun addSuccessfulUpdatedApp(appInstall: AppInstall) = synchronized(lock) { + successfulUpdatedApps.add(appInstall) + } + + fun clearSuccessfulUpdatedApps() = synchronized(lock) { + successfulUpdatedApps.clear() + } + + fun getSuccessfulUpdatedApps(): List = synchronized(lock) { + successfulUpdatedApps.toList() + } + + fun hasSuccessfulUpdatedApps(): Boolean = synchronized(lock) { + successfulUpdatedApps.isNotEmpty() + } +} diff --git a/app/src/main/java/foundation/e/apps/domain/install/GetOtherStoreUpdateConfirmationUseCase.kt b/app/src/main/java/foundation/e/apps/domain/install/GetOtherStoreUpdateConfirmationUseCase.kt new file mode 100644 index 000000000..93161cd08 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/install/GetOtherStoreUpdateConfirmationUseCase.kt @@ -0,0 +1,74 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.domain.install + +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.install.pkg.AppLoungePackageManager +import foundation.e.apps.data.updates.UpdatesManagerRepository +import foundation.e.apps.domain.application.ApplicationDomain +import foundation.e.apps.domain.model.install.Status +import foundation.e.apps.domain.preferences.AppPreferencesRepository +import javax.inject.Inject + +data class OtherStoreUpdateConfirmation( + val appName: String, + val packageName: String, + val existingUpdateOwnerLabel: String, +) + +class GetOtherStoreUpdateConfirmationUseCase @Inject constructor( + private val appPreferencesRepository: AppPreferencesRepository, + private val appLoungePackageManager: AppLoungePackageManager, + private val updatesManagerRepository: UpdatesManagerRepository, +) { + operator fun invoke(application: ApplicationDomain): OtherStoreUpdateConfirmation? { + return application.packageName + .takeIf { shouldShowConfirmation(application) } + ?.let { packageName -> + appLoungePackageManager.getInstallerName(packageName) + .takeIf { it.isNotBlank() } + ?.let { installerPackageName -> + OtherStoreUpdateConfirmation( + appName = application.name.ifBlank { packageName }, + packageName = packageName, + existingUpdateOwnerLabel = getInstallerLabel(installerPackageName), + ) + } + } + } + + private fun shouldShowConfirmation(application: ApplicationDomain): Boolean { + val packageName = application.packageName + return !appPreferencesRepository.shouldUpdateAppsFromOtherStores() && + packageName.isNotBlank() && + !application.isPwa && + application.source != Source.SYSTEM_APP && + appLoungePackageManager.getPackageStatus( + packageName, + application.latestVersionCode + ) == Status.UPDATABLE && + updatesManagerRepository.isAppInstalledFromOtherStore(packageName) + } + + private fun getInstallerLabel(installerPackageName: String): String { + return runCatching { + appLoungePackageManager.getAppNameFromPackageName(installerPackageName) + }.getOrDefault(installerPackageName).ifBlank { installerPackageName } + } +} diff --git a/app/src/main/java/foundation/e/apps/domain/install/InstalledAppUpdateInfoProvider.kt b/app/src/main/java/foundation/e/apps/domain/install/InstalledAppUpdateInfoProvider.kt new file mode 100644 index 000000000..4428ee4b4 --- /dev/null +++ b/app/src/main/java/foundation/e/apps/domain/install/InstalledAppUpdateInfoProvider.kt @@ -0,0 +1,29 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.domain.install + +import foundation.e.apps.domain.model.install.Status + +interface InstalledAppUpdateInfoProvider { + fun getPackageStatus(packageName: String, versionCode: Long): Status + + fun isInstalledFromOtherStore(packageName: String): Boolean + + fun getInstallerLabel(packageName: String): String? +} diff --git a/app/src/main/java/foundation/e/apps/ui/MainActivity.kt b/app/src/main/java/foundation/e/apps/ui/MainActivity.kt index 7e49fd8a1..e417ddabf 100644 --- a/app/src/main/java/foundation/e/apps/ui/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/ui/MainActivity.kt @@ -54,6 +54,7 @@ import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.login.core.StoreType import foundation.e.apps.data.system.ParentalControlAuthenticator import foundation.e.apps.databinding.ActivityMainBinding +import foundation.e.apps.domain.install.OtherStoreUpdateConfirmation import foundation.e.apps.domain.model.User import foundation.e.apps.ui.application.ApplicationFragmentArgs import foundation.e.apps.ui.application.subFrags.ApplicationDialogFragment @@ -81,6 +82,7 @@ class MainActivity : AppCompatActivity() { companion object { private val TAG = MainActivity::class.java.simpleName private const val SESSION_DIALOG_TAG = "session_dialog" + private const val OTHER_STORE_UPDATE_DIALOG_TAG = "other_store_update_dialog" } private val parentalControlAuthenticatorLauncher = registerForActivityResult( @@ -151,6 +153,8 @@ class MainActivity : AppCompatActivity() { observeErrorMessageString() + observeOtherStoreUpdateConfirmation() + observeIsAppPurchased() observePurchaseDeclined() @@ -351,6 +355,44 @@ class MainActivity : AppCompatActivity() { } } + private fun observeOtherStoreUpdateConfirmation() { + viewModel.otherStoreUpdateConfirmation.observe(this) { confirmation -> + if (confirmation == null) { + return@observe + } + + if (supportFragmentManager.findFragmentByTag(OTHER_STORE_UPDATE_DIALOG_TAG) != null) { + return@observe + } + + createOtherStoreUpdateDialog(confirmation) + .show(supportFragmentManager, OTHER_STORE_UPDATE_DIALOG_TAG) + } + } + + private fun createOtherStoreUpdateDialog( + confirmation: OtherStoreUpdateConfirmation + ): ApplicationDialogFragment { + val appIcon = runCatching { + packageManager.getApplicationIcon(confirmation.packageName) + }.getOrNull() + + return ApplicationDialogFragment( + title = confirmation.appName, + message = getString( + R.string.other_store_update_confirmation_message, + getString(R.string.app_name), + confirmation.existingUpdateOwnerLabel + ), + drawable = appIcon, + positiveButtonText = getString(R.string.update_anyway), + positiveButtonAction = { viewModel.confirmOtherStoreUpdateInstall() }, + cancelButtonText = getString(R.string.cancel), + cancelButtonAction = { viewModel.dismissOtherStoreUpdateConfirmation() }, + onDismissListener = { viewModel.dismissOtherStoreUpdateConfirmation() } + ) + } + private fun observeErrorMessage() { viewModel.errorMessage.observe(this) { when (it) { diff --git a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt index a3cd616fc..1223d3eaa 100644 --- a/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt +++ b/app/src/main/java/foundation/e/apps/ui/MainActivityViewModel.kt @@ -41,6 +41,8 @@ import foundation.e.apps.data.installation.model.AppInstall import foundation.e.apps.data.login.core.AuthObject import foundation.e.apps.data.system.NetworkStatusManager import foundation.e.apps.domain.application.ApplicationDomain +import foundation.e.apps.domain.install.GetOtherStoreUpdateConfirmationUseCase +import foundation.e.apps.domain.install.OtherStoreUpdateConfirmation import foundation.e.apps.domain.model.LoginState import foundation.e.apps.domain.model.User import kotlinx.coroutines.Dispatchers @@ -57,6 +59,7 @@ class MainActivityViewModel @Inject constructor( private val appLoungePackageManager: AppLoungePackageManager, private val pwaManager: PwaManager, private val appInstallationFacade: AppInstallationFacade, + private val getOtherStoreUpdateConfirmationUseCase: GetOtherStoreUpdateConfirmationUseCase, private val sessionManager: MainActivitySessionManager, private val startupCoordinator: MainActivityStartupCoordinator, ) : ViewModel() { @@ -80,6 +83,10 @@ class MainActivityViewModel @Inject constructor( val purchaseAppLiveData: LiveData = _purchaseAppLiveData val isAppPurchased: MutableLiveData = MutableLiveData() val purchaseDeclined: MutableLiveData = MutableLiveData() + private var pendingOtherStoreUpdateApp: Application? = null + private val _otherStoreUpdateConfirmation = MutableLiveData() + val otherStoreUpdateConfirmation: LiveData = + _otherStoreUpdateConfirmation lateinit var internetConnection: LiveData // Downloads @@ -294,13 +301,40 @@ class MainActivityViewModel @Inject constructor( } fun getApplication(app: Application) { + requestApplicationInstall(app, app.toOtherStoreUpdateCandidate()) + } + + fun getApplication(homeApp: ApplicationDomain) { + requestApplicationInstall(homeApp.toApplication(), homeApp) + } + + private fun requestApplicationInstall( + application: Application, + domainApplication: ApplicationDomain + ) { + viewModelScope.launch(Dispatchers.IO) { + val confirmation = getOtherStoreUpdateConfirmationUseCase(domainApplication) + if (confirmation != null) { + pendingOtherStoreUpdateApp = application + _otherStoreUpdateConfirmation.postValue(confirmation) + return@launch + } + + appInstallationFacade.initAppInstall(application) + } + } + + fun confirmOtherStoreUpdateInstall() { + val pendingApp = pendingOtherStoreUpdateApp ?: return + dismissOtherStoreUpdateConfirmation() viewModelScope.launch(Dispatchers.IO) { - appInstallationFacade.initAppInstall(app) + appInstallationFacade.initAppInstall(pendingApp) } } - fun getApplication(homeApp: ApplicationDomain) { - getApplication(homeApp.toApplication()) + fun dismissOtherStoreUpdateConfirmation() { + pendingOtherStoreUpdateApp = null + _otherStoreUpdateConfirmation.value = null } suspend fun updateAwaitingForPurchasedApp(packageName: String): AppInstall? { @@ -397,4 +431,12 @@ class MainActivityViewModel @Inject constructor( fun launchPwa(homeApp: ApplicationDomain) { launchPwa(homeApp.toApplication()) } + + private fun Application.toOtherStoreUpdateCandidate() = ApplicationDomain( + name = name, + packageName = package_name, + latestVersionCode = latest_version_code, + source = source, + isPwa = is_pwa, + ) } diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index 601c4ee7a..37ff99ae7 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -219,4 +219,6 @@ App nicht verfügbar Diese App kann nicht installiert werden. Dies liegt in der Regel an Inhaltsbeschränkungen basierend auf Ihrem Standort (Region) oder den Alterseinstellungen des Kontos (einschließlich Altersüberprüfung und Alters-/Inhaltsfreigabe). + Diese App über %1$s aktualisieren?

Diese App erhält ihre Updates normalerweise von %2$s. Wenn Sie sie aus einer anderen Quelle aktualisieren, erhalten Sie künftige Updates möglicherweise aus beliebigen Quellen auf Ihrem Telefon. Die App-Funktionalität kann sich ändern.

]]>
+ Trotzdem aktualisieren diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8e030b745..76d0a469d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -220,4 +220,6 @@ Aplicación no disponible Esta aplicación no está disponible para su instalación. Esto se debe generalmente a restricciones de contenido basadas en tu ubicación (región) o en la configuración de edad de la cuenta (incluida la verificación de edad y la clasificación por edad/contenido). + ¿Actualizar esta aplicación desde %1$s?

Normalmente, esta aplicación recibe actualizaciones de %2$s. Si la actualizas desde una fuente diferente, es posible que en el futuro recibas actualizaciones desde cualquier fuente en tu teléfono. La funcionalidad de la aplicación puede cambiar.

]]>
+ Actualizar de todos modos diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 831bfa11a..ebaf6b941 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -217,4 +217,6 @@ Toiset lähteet sovelluksille voivat antaa hakutuloksia, katso muut välilehdet/asetukset. Järjestelmäsovellus Ei yhteyttä + Päivitetäänkö tämä sovellus lähteestä %1$s?

Tämä sovellus saa päivitykset normaalisti lähteestä %2$s. Jos päivität sen eri lähteestä, saatat jatkossa saada päivityksiä mistä tahansa puhelimesi lähteestä. Sovelluksen toiminta voi muuttua.

]]>
+ Päivitä silti diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index ecfd5a36c..619047b63 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -220,4 +220,6 @@ Application indisponible Cette application n\'est pas disponible à l\'installation. Cela est généralement dû à des restrictions de contenu basées sur votre emplacement (région) ou les paramètres d\'âge du compte (y compris la vérification de l\'âge et la classification par âge/contenu). Avertissement de mise à jour ! + Mettre à jour cette application depuis %1$s ?

Cette application reçoit normalement ses mises à jour depuis %2$s. En la mettant à jour depuis une source différente, vous pourriez recevoir à l’avenir des mises à jour depuis n’importe quelle source sur votre téléphone. Le fonctionnement de l’application peut changer.

]]>
+ Mettre à jour quand même diff --git a/app/src/main/res/values-is/strings.xml b/app/src/main/res/values-is/strings.xml index a181261c0..986080221 100644 --- a/app/src/main/res/values-is/strings.xml +++ b/app/src/main/res/values-is/strings.xml @@ -217,4 +217,6 @@ Önnur upptök forrita gætu gefið niðurstöður, athugaðu aðra flipa og/eða stillingar. Engin tenging Kerfisforrit + Uppfæra þetta forrit frá %1$s?

Þetta forrit fær venjulega uppfærslur frá %2$s. Ef þú uppfærir það frá öðrum uppruna gætirðu fengið framtíðaruppfærslur frá hvaða uppruna sem er í símanum þínum. Virkni forritsins gæti breyst.

]]>
+ Uppfæra samt diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 98ed2bef0..2270109ad 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -220,4 +220,6 @@ App non disponibile Questa app non è disponibile per l\'installazione. Ciò è generalmente dovuto a restrizioni di contenuto basate sulla tua posizione (regione) o sulle impostazioni di età dell\'account (incluse verifica dell\'età e classificazione per età/contenuto). + Aggiornare questa app da %1$s?

Questa app riceve normalmente gli aggiornamenti da %2$s. Aggiornandola da un’origine diversa, in futuro potresti ricevere aggiornamenti da qualsiasi origine sul tuo telefono. Il funzionamento dell’app potrebbe cambiare.

]]>
+ Aggiorna comunque diff --git a/app/src/main/res/values-ja/strings.xml b/app/src/main/res/values-ja/strings.xml index 4954adea0..6ad511871 100644 --- a/app/src/main/res/values-ja/strings.xml +++ b/app/src/main/res/values-ja/strings.xml @@ -190,4 +190,6 @@ オープンソースアプリとWebアプリの読み込み中にエラーが発生しました。現在は一般アプリのみ利用できます。 アプリは利用できません このアプリはインストールできません。これは通常、所在地(地域)やアカウントの年齢設定(年齢確認や年齢/コンテンツ評価を含む)に基づくコンテンツ制限が原因です。 + このアプリを %1$s から更新しますか?

このアプリは通常 %2$s から更新を受け取ります。別の提供元から更新すると、今後は端末上の別の提供元から更新を受け取る可能性があります。アプリの動作が変わる場合があります。

]]>
+ それでも更新する diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index 39fa2472f..685371f8e 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -207,4 +207,6 @@ App Lounge blir lukket under installasjon av oppdateringen. Unngå å gjøre noe i den før oppdateringen (nedlasting + installasjon) er fullført. Du kan bruke den igjen om et par minutter. App ikke tilgjengelig Denne appen er ikke tilgjengelig for installasjon. Dette skyldes vanligvis innholdsbegrensninger basert på din plassering (region) eller kontoens aldersinnstillinger (inkludert aldersverifisering og alders-/innholdsvurdering). + Oppdatere denne appen fra %1$s?

Denne appen mottar vanligvis oppdateringer fra %2$s. Hvis du oppdaterer den fra en annen kilde, kan du senere motta oppdateringer fra hvilken som helst kilde på telefonen din. Appens funksjonalitet kan endres.

]]>
+ Oppdater likevel diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 4c00d02c0..dd7c903f0 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -217,4 +217,6 @@ Andere appbronnen geven mogelijk wel resultaten, controleer de andere tabs/jouw instellingen. Systeemapp Geen verbinding + Deze app bijwerken via %1$s?

Deze app ontvangt normaal updates van %2$s. Als je de app via een andere bron bijwerkt, kun je in de toekomst updates ontvangen van elke bron op je telefoon. De werking van de app kan veranderen.

]]>
+ Toch bijwerken diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 7fd4223a6..965ee0c2b 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -202,4 +202,6 @@ Outras fontes de aplicativos podem fornecer resultados. Verifique as outras abas/suas configurações. Sem conexão Aplicativo de sistema + Atualizar este aplicativo por %1$s?

Normalmente, este aplicativo recebe atualizações de %2$s. Ao atualizá-lo por uma fonte diferente, você poderá receber atualizações futuras de qualquer fonte no seu telefone. O funcionamento do aplicativo pode mudar.

]]>
+ Atualizar mesmo assim diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index ac04b80cb..c9fb92157 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -209,4 +209,6 @@ Не удалось выполнить общесистемный вход Google Приложение недоступно Это приложение недоступно для установки. Обычно это связано с ограничениями контента в зависимости от вашего местоположения (региона) или настроек возраста учетной записи (включая подтверждение возраста и возрастной/контентный рейтинг). + Обновить это приложение через %1$s?

Обычно это приложение получает обновления через %2$s. Если обновить его из другого источника, в будущем вы можете получать обновления из любого источника на вашем телефоне. Работа приложения может измениться.

]]>
+ Все равно обновить diff --git a/app/src/main/res/values-sk/strings.xml b/app/src/main/res/values-sk/strings.xml index 5e772460b..e3afa432c 100644 --- a/app/src/main/res/values-sk/strings.xml +++ b/app/src/main/res/values-sk/strings.xml @@ -203,4 +203,6 @@ Stlačte Skúsiť znova.
Pri načítavaní Open Source a Web aplikácií došlo k chybe. Zatiaľ sú dostupné iba bežné aplikácie. Aplikácia nie je dostupná Táto aplikácia nie je dostupná na inštaláciu. Zvyčajne je to spôsobené obsahovými obmedzeniami na základe vašej polohy (regiónu) alebo vekových nastavení účtu (vrátane overenia veku a vekového/obsahového hodnotenia). + Aktualizovať túto aplikáciu cez %1$s?

Táto aplikácia zvyčajne dostáva aktualizácie cez %2$s. Ak ju aktualizujete z iného zdroja, v budúcnosti môžete dostávať aktualizácie z ľubovoľného zdroja vo vašom telefóne. Funkčnosť aplikácie sa môže zmeniť.

]]>
+ Napriek tomu aktualizovať diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 0ba2ce51a..8677f5862 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -207,4 +207,6 @@ Systemomfattande Google-inloggning misslyckades App ej tillgänglig Den här appen är inte tillgänglig för installation. Detta beror vanligtvis på innehållsbegränsningar baserade på din plats (region) eller kontots åldersinställningar (inklusive åldersverifiering och ålders-/innehållsbedömning). + Uppdatera den här appen via %1$s?

Den här appen får normalt uppdateringar från %2$s. Om du uppdaterar den från en annan källa kan du framöver få uppdateringar från vilken källa som helst på din telefon. Appens funktion kan förändras.

]]>
+ Uppdatera ändå diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 264e53c6c..77766e2a6 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -199,4 +199,6 @@ Açık Kaynak\'ta sonuç yok Web Uygulamaları\'nda sonuç yok Diğer uygulama kaynakları sonuç sunabilir, lütfen diğer sekmeleri/ayarlarınızı kontrol edin. + Bu uygulama %1$s üzerinden güncellensin mi?

Bu uygulama normalde güncellemelerini %2$s üzerinden alır. Farklı bir kaynaktan güncellerseniz, gelecekte telefonunuzdaki herhangi bir kaynaktan güncelleme alabilirsiniz. Uygulamanın işlevleri değişebilir.

]]>
+ Yine de güncelle diff --git a/app/src/main/res/values-uk/strings.xml b/app/src/main/res/values-uk/strings.xml index 516ca96ef..50b6d430c 100644 --- a/app/src/main/res/values-uk/strings.xml +++ b/app/src/main/res/values-uk/strings.xml @@ -209,4 +209,6 @@ Під час завантаження застосунків з відкритим кодом і Web застосунків сталася помилка. Наразі доступні лише поширені застосунки. Застосунок недоступний Цей застосунок недоступний для встановлення. Зазвичай це пов\'язано з обмеженнями вмісту залежно від вашого місцезнаходження (регіону) або вікових налаштувань облікового запису (включно з перевіркою віку та віковим/контентним рейтингом). + Оновити цей застосунок через %1$s?

Зазвичай цей застосунок отримує оновлення через %2$s. Якщо оновити його з іншого джерела, надалі ви можете отримувати оновлення з будь-якого джерела на вашому телефоні. Робота застосунку може змінитися.

]]>
+ Все одно оновити diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9296fb7e9..266e5c51e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -81,6 +81,7 @@ Update apps automatically only on un-metered networks such as Wi-Fi Update apps installed by other stores Update apps installed from other app stores.\nSuch apps will be attempted to be updated from common apps and open source category. + Update this app from %1$s?

This app normally receives updates from %2$s. By updating from a different source, you may receive future updates from any source on your phone. App functionality may change.

]]>
Automatically install updates Download and install app updates in the background Show available updates @@ -114,6 +115,7 @@ Close More Update + Update anyway Ratings Rating Score out of 5. Computed using users\' ratings of the app. diff --git a/app/src/test/java/foundation/e/apps/FakeAppLoungePackageManager.kt b/app/src/test/java/foundation/e/apps/FakeAppLoungePackageManager.kt index 37da4a13e..ea0c0086a 100644 --- a/app/src/test/java/foundation/e/apps/FakeAppLoungePackageManager.kt +++ b/app/src/test/java/foundation/e/apps/FakeAppLoungePackageManager.kt @@ -21,14 +21,14 @@ package foundation.e.apps import android.content.Context import android.content.pm.ApplicationInfo import foundation.e.apps.data.application.data.Application -import foundation.e.apps.data.updates.UpdatesManagerImpl.Companion.PACKAGE_NAME_ANDROID_VENDING -import foundation.e.apps.data.updates.UpdatesManagerImpl.Companion.PACKAGE_NAME_F_DROID import foundation.e.apps.data.install.pkg.AppLoungePackageManager +import foundation.e.apps.data.updates.UpdatesManagerImpl open class FakeAppLoungePackageManager( - context: Context, + private val appContext: Context, private val gplayApps: List = emptyList(), -) : AppLoungePackageManager(context) { + private val otherStoreApps: Set = emptySet(), +) : AppLoungePackageManager(appContext) { val applicationInfo = mutableListOf( ApplicationInfo().apply { this.packageName = "foundation.e.demoone" }, @@ -43,10 +43,10 @@ open class FakeAppLoungePackageManager( override fun getInstallerName(packageName: String): String { val gplayPackageNames = gplayApps.map { it.package_name } - return if (gplayPackageNames.contains(packageName)) { - PACKAGE_NAME_ANDROID_VENDING - } else { - PACKAGE_NAME_F_DROID + return when { + gplayPackageNames.contains(packageName) -> FAKE_STORE_PACKAGE_NAME + otherStoreApps.contains(packageName) -> UpdatesManagerImpl.PACKAGE_NAME_F_DROID + else -> appContext.packageName } } } diff --git a/app/src/test/java/foundation/e/apps/UpdateManagerImptTest.kt b/app/src/test/java/foundation/e/apps/UpdateManagerImptTest.kt index e39559c16..14d2a9835 100644 --- a/app/src/test/java/foundation/e/apps/UpdateManagerImptTest.kt +++ b/app/src/test/java/foundation/e/apps/UpdateManagerImptTest.kt @@ -36,6 +36,7 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -86,6 +87,7 @@ class UpdateManagerImptTest { @Before fun setup() { MockitoAnnotations.openMocks(this) + Mockito.`when`(context.packageName).thenReturn("foundation.e.apps") faultyAppRepository = FaultyAppRepository(FakeFaultyAppDao()) preferenceModule = FakeAppLoungePreference() pkgManagerModule = FakeAppLoungePackageManager(context, getGplayApps()) @@ -151,6 +153,84 @@ class UpdateManagerImptTest { ), ) + @Test + fun isAppInstalledFromOtherStoreReturnsTrueForFDroidInstalledApp() { + val fdroidUpdatesManager = UpdatesManagerImpl( + context, + FakeAppLoungePackageManager( + appContext = context, + gplayApps = getGplayApps(), + otherStoreApps = setOf("foundation.e.demothree") + ), + applicationRepository, + faultyAppRepository, + preferenceModule, + fDroidRepository, + blockedAppRepository, + systemAppsUpdatesRepository, + ) + + assertTrue( + fdroidUpdatesManager.isAppInstalledFromOtherStore("foundation.e.demothree") + ) + } + + @Test + fun getUpdateSkipsFDroidInstalledAppWhenOtherStoreUpdatesDisabled() = runTest { + preferenceModule.shouldUpdateFromOtherStores = false + val fdroidUpdatesManager = UpdatesManagerImpl( + context, + FakeAppLoungePackageManager( + appContext = context, + gplayApps = getGplayApps(), + otherStoreApps = setOf("foundation.e.demothree") + ), + applicationRepository, + faultyAppRepository, + preferenceModule, + fDroidRepository, + blockedAppRepository, + systemAppsUpdatesRepository, + ) + + setupMockingForFetchingUpdates( + openSourceUpdates = Pair(getOpenSourceApps(Status.UPDATABLE), ResultStatus.OK), + gplayUpdates = Pair(getGplayApps(Status.UPDATABLE), ResultStatus.OK), + ) + + val updateResult = fdroidUpdatesManager.getUpdates() + + assertFalse(updateResult.first.any { it.package_name == "foundation.e.demothree" }) + } + + @Test + fun getUpdateIncludesFDroidInstalledAppWhenOtherStoreUpdatesEnabled() = runTest { + preferenceModule.shouldUpdateFromOtherStores = true + val fdroidUpdatesManager = UpdatesManagerImpl( + context, + FakeAppLoungePackageManager( + appContext = context, + gplayApps = getGplayApps(), + otherStoreApps = setOf("foundation.e.demothree") + ), + applicationRepository, + faultyAppRepository, + preferenceModule, + fDroidRepository, + blockedAppRepository, + systemAppsUpdatesRepository, + ) + + setupMockingForFetchingUpdates( + openSourceUpdates = Pair(getOpenSourceApps(Status.UPDATABLE), ResultStatus.OK), + gplayUpdates = Pair(getGplayApps(Status.INSTALLED), ResultStatus.OK), + ) + + val updateResult = fdroidUpdatesManager.getUpdates() + + assertTrue(updateResult.first.any { it.package_name == "foundation.e.demothree" }) + } + @Test fun getUpdateWhenInstalledPackageListIsEmpty() = runTest { val authData = AuthData("e@e.email", "AtadyMsIAtadyM") diff --git a/app/src/test/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapperTest.kt b/app/src/test/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapperTest.kt index 3898e3aed..c07aeca0b 100644 --- a/app/src/test/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapperTest.kt +++ b/app/src/test/java/foundation/e/apps/data/application/mapper/ApplicationDomainMapperTest.kt @@ -19,8 +19,8 @@ package foundation.e.apps.data.application.mapper import com.google.common.truth.Truth.assertThat import foundation.e.apps.data.enums.Source -import foundation.e.apps.domain.model.install.Status import foundation.e.apps.domain.application.ApplicationDomain +import foundation.e.apps.domain.model.install.Status import org.junit.Test class ApplicationDomainMapperTest { diff --git a/app/src/test/java/foundation/e/apps/domain/install/GetOtherStoreUpdateConfirmationUseCaseTest.kt b/app/src/test/java/foundation/e/apps/domain/install/GetOtherStoreUpdateConfirmationUseCaseTest.kt new file mode 100644 index 000000000..c35e146a2 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/domain/install/GetOtherStoreUpdateConfirmationUseCaseTest.kt @@ -0,0 +1,151 @@ +/* + * 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 . + * + */ + +package foundation.e.apps.domain.install + +import com.google.common.truth.Truth.assertThat +import foundation.e.apps.FakeAppLoungePreference +import foundation.e.apps.data.enums.Source +import foundation.e.apps.data.install.pkg.AppLoungePackageManager +import foundation.e.apps.data.updates.UpdatesManagerRepository +import foundation.e.apps.domain.application.ApplicationDomain +import foundation.e.apps.domain.model.install.Status +import io.mockk.every +import io.mockk.mockk +import org.junit.Before +import org.junit.Test + +class GetOtherStoreUpdateConfirmationUseCaseTest { + + private val appPreferencesRepository = FakeAppLoungePreference() + private val packageManager = mockk() + private val updatesManagerRepository = mockk() + private lateinit var useCase: GetOtherStoreUpdateConfirmationUseCase + + @Before + fun setUp() { + every { packageManager.getPackageStatus(any(), any()) } returns Status.UNAVAILABLE + every { packageManager.getInstallerName(any()) } returns "" + every { packageManager.getAppNameFromPackageName(any()) } returns "" + every { updatesManagerRepository.isAppInstalledFromOtherStore(any()) } returns false + + useCase = GetOtherStoreUpdateConfirmationUseCase( + appPreferencesRepository, + packageManager, + updatesManagerRepository + ) + } + + @Test + fun returnsConfirmationWhenSettingDisabledAndAppIsInstalledFromOtherStore() { + appPreferencesRepository.shouldUpdateFromOtherStores = false + every { packageManager.getPackageStatus(any(), any()) } returns Status.UPDATABLE + every { updatesManagerRepository.isAppInstalledFromOtherStore("org.example.app") } returns true + every { packageManager.getInstallerName("org.example.app") } returns "com.aurora.store" + every { packageManager.getAppNameFromPackageName("com.aurora.store") } returns "Aurora Store" + + val confirmation = useCase( + ApplicationDomain( + name = "Signal", + packageName = "org.example.app" + ) + ) + + assertThat(confirmation).isEqualTo( + OtherStoreUpdateConfirmation( + appName = "Signal", + packageName = "org.example.app", + existingUpdateOwnerLabel = "Aurora Store" + ) + ) + } + + @Test + fun fallsBackToPackageNameWhenAppNameIsBlank() { + appPreferencesRepository.shouldUpdateFromOtherStores = false + every { packageManager.getPackageStatus(any(), any()) } returns Status.UPDATABLE + every { updatesManagerRepository.isAppInstalledFromOtherStore("org.example.app") } returns true + every { packageManager.getInstallerName("org.example.app") } returns "com.aurora.store" + every { packageManager.getAppNameFromPackageName("com.aurora.store") } returns "Aurora Store" + + val confirmation = useCase(ApplicationDomain(packageName = "org.example.app")) + + assertThat(confirmation?.appName).isEqualTo("org.example.app") + } + + @Test + fun returnsNullWhenSettingEnabled() { + appPreferencesRepository.shouldUpdateFromOtherStores = true + every { packageManager.getPackageStatus(any(), any()) } returns Status.UPDATABLE + every { updatesManagerRepository.isAppInstalledFromOtherStore(any()) } returns true + + val confirmation = useCase(ApplicationDomain(packageName = "org.example.app")) + + assertThat(confirmation).isNull() + } + + @Test + fun returnsNullWhenAppIsNotInstalledFromOtherStore() { + appPreferencesRepository.shouldUpdateFromOtherStores = false + every { packageManager.getPackageStatus(any(), any()) } returns Status.UPDATABLE + every { updatesManagerRepository.isAppInstalledFromOtherStore("org.example.app") } returns false + + val confirmation = useCase(ApplicationDomain(packageName = "org.example.app")) + + assertThat(confirmation).isNull() + } + + @Test + fun returnsNullWhenInstallerPackageNameIsBlank() { + appPreferencesRepository.shouldUpdateFromOtherStores = false + every { packageManager.getPackageStatus(any(), any()) } returns Status.UPDATABLE + every { updatesManagerRepository.isAppInstalledFromOtherStore("org.example.app") } returns true + every { packageManager.getInstallerName("org.example.app") } returns "" + + val confirmation = useCase(ApplicationDomain(packageName = "org.example.app")) + + assertThat(confirmation).isNull() + } + + @Test + fun returnsNullWhenAppIsNotUpdatable() { + appPreferencesRepository.shouldUpdateFromOtherStores = false + every { packageManager.getPackageStatus(any(), any()) } returns Status.INSTALLED + every { updatesManagerRepository.isAppInstalledFromOtherStore(any()) } returns true + + val confirmation = useCase(ApplicationDomain(packageName = "org.example.app")) + + assertThat(confirmation).isNull() + } + + @Test + fun returnsNullForSystemApps() { + appPreferencesRepository.shouldUpdateFromOtherStores = false + every { packageManager.getPackageStatus(any(), any()) } returns Status.UPDATABLE + every { updatesManagerRepository.isAppInstalledFromOtherStore(any()) } returns true + + val confirmation = useCase( + ApplicationDomain( + packageName = "org.example.app", + source = Source.SYSTEM_APP + ) + ) + + assertThat(confirmation).isNull() + } +} diff --git a/app/src/test/java/foundation/e/apps/ui/MainActivityViewModelTest.kt b/app/src/test/java/foundation/e/apps/ui/MainActivityViewModelTest.kt new file mode 100644 index 000000000..e17fc3125 --- /dev/null +++ b/app/src/test/java/foundation/e/apps/ui/MainActivityViewModelTest.kt @@ -0,0 +1,196 @@ +/* + * 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 . + */ + +package foundation.e.apps.ui + +import androidx.arch.core.executor.testing.InstantTaskExecutorRule +import androidx.lifecycle.MutableLiveData +import com.google.common.truth.Truth.assertThat +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.install.core.AppInstallationFacade +import foundation.e.apps.data.install.pkg.AppLoungePackageManager +import foundation.e.apps.data.install.pkg.PwaManager +import foundation.e.apps.domain.application.ApplicationDomain +import foundation.e.apps.domain.install.GetOtherStoreUpdateConfirmationUseCase +import foundation.e.apps.domain.install.OtherStoreUpdateConfirmation +import foundation.e.apps.domain.model.LoginState +import foundation.e.apps.domain.model.User +import foundation.e.apps.util.MainCoroutineRule +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.every +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class MainActivityViewModelTest { + + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainCoroutineRule = MainCoroutineRule() + + private val applicationRepository = mockk(relaxed = true) + private val appManager = mockk(relaxed = true) + private val appLoungePackageManager = mockk(relaxed = true) + private val pwaManager = mockk(relaxed = true) + private val appInstallationFacade = mockk(relaxed = true) + private val getOtherStoreUpdateConfirmationUseCase = + mockk() + private val sessionManager = mockk() + private val startupCoordinator = mockk(relaxed = true) + + private lateinit var viewModel: MainActivityViewModel + + @Before + fun setUp() { + every { appManager.getDownloadLiveList() } returns MutableLiveData(emptyList()) + every { sessionManager.tocStatus } returns MutableStateFlow(true) + every { sessionManager.user } returns MutableStateFlow(User.NO_GOOGLE) + every { sessionManager.loginState } returns MutableStateFlow(LoginState.AVAILABLE) + coEvery { sessionManager.awaitTocStatus() } returns true + coEvery { sessionManager.awaitUser() } returns User.NO_GOOGLE + coEvery { sessionManager.awaitLoginState() } returns LoginState.AVAILABLE + coEvery { appInstallationFacade.initAppInstall(any(), any()) } returns true + + viewModel = MainActivityViewModel( + applicationRepository, + appManager, + appLoungePackageManager, + pwaManager, + appInstallationFacade, + getOtherStoreUpdateConfirmationUseCase, + sessionManager, + startupCoordinator, + ) + } + + @Test + fun getApplication_postsConfirmationAndSkipsInstall_whenConfirmationIsRequired() = runTest { + val app = Application( + name = "Signal", + package_name = "org.signal", + ) + val confirmation = OtherStoreUpdateConfirmation( + appName = "Signal", + packageName = "org.signal", + existingUpdateOwnerLabel = "Aurora Store" + ) + every { + getOtherStoreUpdateConfirmationUseCase( + ApplicationDomain( + name = "Signal", + packageName = "org.signal", + ) + ) + } returns confirmation + + viewModel.getApplication(app) + advanceUntilIdle() + awaitConfirmation() + + assertThat(viewModel.otherStoreUpdateConfirmation.value).isEqualTo(confirmation) + coVerify(exactly = 0) { appInstallationFacade.initAppInstall(any(), any()) } + } + + @Test + fun confirmOtherStoreUpdateInstall_startsPendingInstallAndClearsConfirmation() = runTest { + val app = Application( + name = "Signal", + package_name = "org.signal", + ) + val confirmation = OtherStoreUpdateConfirmation( + appName = "Signal", + packageName = "org.signal", + existingUpdateOwnerLabel = "Aurora Store" + ) + every { + getOtherStoreUpdateConfirmationUseCase( + ApplicationDomain( + name = "Signal", + packageName = "org.signal", + ) + ) + } returns confirmation + + viewModel.getApplication(app) + advanceUntilIdle() + awaitConfirmation() + + viewModel.confirmOtherStoreUpdateInstall() + advanceUntilIdle() + + assertThat(viewModel.otherStoreUpdateConfirmation.value).isNull() + coVerify(timeout = 1000, exactly = 1) { + appInstallationFacade.initAppInstall(app, false) + } + } + + @Test + fun dismissOtherStoreUpdateConfirmation_clearsPendingConfirmation() = runTest { + val app = Application( + name = "Signal", + package_name = "org.signal", + ) + val confirmation = OtherStoreUpdateConfirmation( + appName = "Signal", + packageName = "org.signal", + existingUpdateOwnerLabel = "Aurora Store" + ) + every { + getOtherStoreUpdateConfirmationUseCase( + ApplicationDomain( + name = "Signal", + packageName = "org.signal", + ) + ) + } returns confirmation + + viewModel.getApplication(app) + advanceUntilIdle() + awaitConfirmation() + + viewModel.dismissOtherStoreUpdateConfirmation() + viewModel.confirmOtherStoreUpdateInstall() + advanceUntilIdle() + + assertThat(viewModel.otherStoreUpdateConfirmation.value).isNull() + coVerify(exactly = 0) { appInstallationFacade.initAppInstall(any(), any()) } + } + + /** + * [MutableLiveData.postValue] dispatches to the main thread asynchronously. + * With [InstantTaskExecutorRule], it still needs a brief yield for the + * IO-dispatched coroutine to complete and the posted value to arrive. + */ + private fun awaitConfirmation(timeoutMs: Long = 500) { + val deadline = System.currentTimeMillis() + timeoutMs + while (System.currentTimeMillis() < deadline) { + if (viewModel.otherStoreUpdateConfirmation.value != null) return + Thread.sleep(10) + } + } +} diff --git a/app/src/test/java/foundation/e/apps/ui/updates/UpdatesViewModelTest.kt b/app/src/test/java/foundation/e/apps/ui/updates/UpdatesViewModelTest.kt index aad021a41..f9fae3b6e 100644 --- a/app/src/test/java/foundation/e/apps/ui/updates/UpdatesViewModelTest.kt +++ b/app/src/test/java/foundation/e/apps/ui/updates/UpdatesViewModelTest.kt @@ -3,6 +3,7 @@ package foundation.e.apps.ui.updates import androidx.arch.core.executor.testing.InstantTaskExecutorRule import foundation.e.apps.data.Stores import foundation.e.apps.data.application.ApplicationRepository +import foundation.e.apps.data.application.UpdatesDao import foundation.e.apps.data.application.data.Application import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.Source @@ -71,6 +72,7 @@ class UpdatesViewModelTest { @Before fun setup() = runBlocking { MockitoAnnotations.openMocks(this) + UpdatesDao.addItemsForUpdate(emptyList()) whenever(updatesManagerImpl.getUpdates()) .thenReturn(Pair(allUpdates, ResultStatus.OK)) @@ -87,8 +89,13 @@ class UpdatesViewModelTest { .thenReturn(LoginState.AVAILABLE) whenever(appPreferencesRepository.isPlayStoreSelected()) .thenReturn(true) + whenever(appPreferencesRepository.shouldUpdateAppsFromOtherStores()) + .thenReturn(false) - updatesManagerRepository = UpdatesManagerRepository(updatesManagerImpl) + updatesManagerRepository = UpdatesManagerRepository( + updatesManagerImpl, + appPreferencesRepository + ) updatesViewModel = UpdatesViewModel( updatesManagerRepository = updatesManagerRepository, @@ -172,4 +179,32 @@ class UpdatesViewModelTest { assert(updates.containsAll(ossUpdates)) } + @Test + fun `insure updates are refreshed when other stores preference changes`() = runBlocking { + val disabledUpdates = listOf( + Application(name = "Direct update", package_name = "io.murena.direct") + ) + val enabledUpdates = listOf( + Application(name = "Direct update", package_name = "io.murena.direct"), + Application(name = "Fdroid update", package_name = "io.murena.fdroid") + ) + + UpdatesDao.addItemsForUpdate(emptyList()) + whenever(appPreferencesRepository.shouldUpdateAppsFromOtherStores()) + .thenReturn(false, true) + whenever(updatesManagerImpl.getUpdates()) + .thenReturn( + Pair(disabledUpdates, ResultStatus.OK), + Pair(enabledUpdates, ResultStatus.OK) + ) + + val repository = UpdatesManagerRepository( + updatesManagerImpl, + appPreferencesRepository + ) + + assert(repository.getUpdates().first == disabledUpdates) + assert(repository.getUpdates().first == enabledUpdates) + } + } -- GitLab