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 e99c0cd03dea86e7394eefc53006b84507232631..ea5029841c418ec27096deaf70a0f39329b5e219 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 9b0ca6ae82c28753b2ef39f45318a604146322fe..02aab689688dfe0b3f012565a6d51c6e498e9f40 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 0000000000000000000000000000000000000000..f55e58f28e3012a1b386940e19face1aaeb418d8 --- /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 e4fbbdbc0c415fce5718e67534a7764da7b92ee8..42de6f35f66c861ef03d0fb76622fbe23791220d 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 202f39518b88a8da4eeebb745626498eeb01f7bc..ba8e51d01c5910afdadaecfe6448975a9e8892f7 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 0000000000000000000000000000000000000000..7e8588432058be9971dcb7d73d13ad08d12f98d5 --- /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 0000000000000000000000000000000000000000..93161cd08fba0422fa9071827383a6f682671309 --- /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 0000000000000000000000000000000000000000..4428ee4b40e157224c4091ff5e8a1fc95f2b784c --- /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 7e49fd8a1c531c670c1bc1054b5203c7759a7e7e..e417ddabf9e6b417ef1a03831cb85a6199defc01 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 a3cd616fc921a1c38018eeab5f4be96b9ab3c4b9..1223d3eaaebcdfbd6123b9495b031882a66fcc1d 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 57b88ccbaba716f8c57ef4dc519f7a5e7be6f031..8b1ea2eaa051565aec3dc55afa88cf9b29a3a63d 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 8e030b745b2d5e43de38899cae533bd09284a090..76d0a469d3c95c5fb0d7ad3d03c8c4d7b547669f 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 831bfa11a52a5cd83662258e6a755740a3c0ae5b..ebaf6b9415edc9776a3956cc10f9341206f97647 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 ecfd5a36c5ac1eeefeac9bed714923339eab38d8..93f01dc300d7dde0ea2778f77285b87778cd08b7 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 a181261c05d734d71af390c58806a698c871e737..98608022126dc35352eabb6ac2382a64ef76014f 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 98ed2bef08d2f64cf5082e1ebea5dd15735cc7e7..c9b97b0349cc44e72de3d00995ff338a90930170 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 4954adea08203ef2176c0b366e75d12b99888377..6ad5118714f31669a111e27dbcaf5b87113984ce 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 39fa2472fb28b5072a6d1238206f5c8523a401f2..685371f8e7f8795ecc6b5c4307cf8c1f95178953 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 4c00d02c0aefe5281ceef1f2bc0d79f3c8225188..dd7c903f07b9d2447f0ff8dc65d184def5479aaa 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 7fd4223a670bdd81c02872e64ad7e4e565b8ec39..965ee0c2b42bcc11b78a50f2218937df68678a71 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 ac04b80cbc3dfd7d93543786303edb0ef0aee801..c9fb921575f01915b8361079df3993ec81bb81df 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 5e772460baf45f4216f365cdf0bcf448eb48bba2..e3afa432c2e2f4ad85b8f1d860694a1e2b684195 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 0ba2ce51a6eb77414260dc10d7d1ea393e38213a..8677f586280e06164b14391742b8e28b087b662e 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 264e53c6c1a79ca39f5aa5936c52069e39d51e4b..77766e2a6e795a92f160015864ad99a8f1f27abd 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 516ca96ef33ca5d10da5562b6e2376e4ea4033e3..50b6d430c3d16ea1b84bdbbe72c0a82bfa4b9952 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 9296fb7e9a437560869650ad18364a77d2f08df1..266e5c51e8bbbe9014dd80520ddd1915348b5697 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 37da4a13ed254990d73590321ab3cff97f466aa0..ea0c0086a34d91462f23212db5fcc757e755b8f4 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 e39559c16fa30ac9a55cfd8889cf563bb2ac206f..14d2a9835d0c9c9e4557638e14ff3ed989edd493 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 3898e3aed6a01fa3b06a602ef8c5c3b1470a4904..c07aeca0bac48147a51f8c6f62361d831b414450 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 0000000000000000000000000000000000000000..c35e146a2b0481c9338fadf5fc5f0afd5cb660a3 --- /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 0000000000000000000000000000000000000000..e17fc3125c6cd9ddf3b8a3dde9aac6ca7f04a215 --- /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 aad021a41a0ae58660ec0cb597fd7c3d721eede3..f9fae3b6e23ee6d5c27e4d18b6388f54c50ef2c6 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) + } + }