From 771577301c40591e171c420ac61eaa0a3d25fafe Mon Sep 17 00:00:00 2001 From: dev-12 Date: Mon, 5 Jan 2026 01:21:01 +0530 Subject: [PATCH 1/2] fix: disable "update apps from other store" feature It is broken because we don't handle the update ownership user consent dialog, as per the decision in 3710. --- .../e/apps/data/preference/AppLoungePreference.kt | 7 ++++++- .../java/foundation/e/apps/ui/settings/SettingsFragment.kt | 6 ++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt b/app/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt index 8571d005c..6780aa649 100644 --- a/app/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt +++ b/app/src/main/java/foundation/e/apps/data/preference/AppLoungePreference.kt @@ -20,6 +20,7 @@ package foundation.e.apps.data.preference import android.content.Context +import android.os.Build import androidx.core.content.edit import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext @@ -40,6 +41,10 @@ class AppLoungePreference @Inject constructor( private val appLoungeDataStore: AppLoungeDataStore ) { + companion object { + val isUpdateFromOtherStoreSupported = Build.VERSION.SDK_INT <= Build.VERSION_CODES.UPSIDE_DOWN_CAKE + } + private val preferenceManager = PreferenceManager.getDefaultSharedPreferences(context) fun preferredApplicationType(): String { @@ -79,7 +84,7 @@ class AppLoungePreference @Inject constructor( } } - fun shouldUpdateAppsFromOtherStores() = preferenceManager.getBoolean( + fun shouldUpdateAppsFromOtherStores() = isUpdateFromOtherStoreSupported && preferenceManager.getBoolean( context.getString(R.string.update_apps_from_other_stores), true ) diff --git a/app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt b/app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt index 95731e092..ab9834eb0 100644 --- a/app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt +++ b/app/src/main/java/foundation/e/apps/ui/settings/SettingsFragment.kt @@ -46,6 +46,7 @@ import foundation.e.apps.data.Stores import foundation.e.apps.data.application.UpdatesDao import foundation.e.apps.data.enums.Source import foundation.e.apps.data.enums.User +import foundation.e.apps.data.preference.AppLoungePreference import foundation.e.apps.databinding.CustomPreferenceBinding import foundation.e.apps.install.updates.UpdatesWorkManager import foundation.e.apps.ui.LoginViewModel @@ -139,6 +140,11 @@ class SettingsFragment : PreferenceFragmentCompat() { it?.onPreferenceChangeListener = sourceCheckboxListener } + if (!AppLoungePreference.isUpdateFromOtherStoreSupported) { + val key = getString(R.string.update_apps_from_other_stores) + findPreference(key)?.isVisible = false + } + val troubleshootUrl = getString(R.string.troubleshootURL, Locale.getDefault().language) troubleShootPreference?.intent = Intent(Intent.ACTION_VIEW, Uri.parse(troubleshootUrl)) } -- GitLab From 4db02dd87753488f3f02d7c9c4882a802d161420 Mon Sep 17 00:00:00 2001 From: dev-12 Date: Mon, 5 Jan 2026 10:40:43 +0530 Subject: [PATCH 2/2] Fix: handle update ownership transfer consent Add support for update ownership transfer consent required by newer versions android. This handles the system intent that allows users to consent to transferring update ownership to our app store. See: https://source.android.com/docs/setup/create/app-ownership --- .../foundation/e/apps/AppLoungeApplication.kt | 86 +++++++++++++++++++ .../e/apps/install/pkg/InstallerService.kt | 54 +++++++++++- .../java/foundation/e/apps/ui/MainActivity.kt | 63 ++++++++------ .../e/apps/utils/eventBus/AppEvent.kt | 8 +- app/src/main/res/values/strings.xml | 5 ++ 5 files changed, 187 insertions(+), 29 deletions(-) diff --git a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt index 6adbcb225..4ecd9bf2d 100644 --- a/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt +++ b/app/src/main/java/foundation/e/apps/AppLoungeApplication.kt @@ -18,11 +18,21 @@ package foundation.e.apps +import android.Manifest import android.app.Application +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager import android.os.Build import android.util.Log import androidx.annotation.RequiresApi +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat import androidx.hilt.work.HiltWorkerFactory +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.ProcessLifecycleOwner import androidx.work.Configuration import androidx.work.ExistingPeriodicWorkPolicy import dagger.hilt.android.HiltAndroidApp @@ -30,22 +40,31 @@ import foundation.e.apps.data.Constants.TAG_APP_INSTALL_STATE import foundation.e.apps.data.Constants.TAG_AUTHDATA_DUMP import foundation.e.apps.data.preference.AppLoungeDataStore import foundation.e.apps.data.preference.AppLoungePreference +import foundation.e.apps.di.NotificationManagerModule import foundation.e.apps.install.pkg.AppLoungePackageManager import foundation.e.apps.install.pkg.PkgManagerBR import foundation.e.apps.install.updates.UpdatesWorkManager import foundation.e.apps.install.workmanager.InstallWorkManager import foundation.e.apps.ui.setup.tos.TOS_VERSION import foundation.e.apps.utils.CustomUncaughtExceptionHandler +import foundation.e.apps.utils.eventBus.AppEvent +import foundation.e.apps.utils.eventBus.EventBus import foundation.e.lib.telemetry.Telemetry import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.MainScope +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.plus import kotlinx.coroutines.runBlocking import timber.log.Timber import timber.log.Timber.Forest.plant import java.util.concurrent.Executors import javax.inject.Inject +import kotlin.jvm.optionals.getOrDefault @HiltAndroidApp @DelicateCoroutinesApi @@ -109,6 +128,73 @@ class AppLoungeApplication : Application(), Configuration.Provider { appLoungePreference.getUpdateInterval(), ExistingPeriodicWorkPolicy.KEEP ) + listenForUserConformationEvent(this) + } + + private fun listenForUserConformationEvent(context: Context) { + fun isOnForeground(): Boolean { + val lifecycle = ProcessLifecycleOwner.get().lifecycle.currentState + return lifecycle.isAtLeast(Lifecycle.State.RESUMED) + } + + fun sendNotification(event: AppEvent.NeedUserConfirmation) { + if (ActivityCompat.checkSelfPermission( + this, + Manifest.permission.POST_NOTIFICATIONS + ) != PackageManager.PERMISSION_GRANTED + ) { + val errorMsg = "app lounge doesn't have notification permission?" + Timber.i(errorMsg) + throw IllegalStateException(errorMsg) + } + + val intent = event.confirmation + + val appName = event.appName.getOrDefault(getString(R.string.default_updatable_app_name)) + val storeName = with(event) { + installerName.getOrDefault(getString(R.string.default_other_store_name)) + } + val notificationTitle = getString( + R.string.notification_update_owner_request_title, + appName + ) + val notificationContent = getString( + R.string.notification_update_owner_request_content, + appName, + storeName + ) + val uniqueActionId = intent.hashCode() + val notificationManager = NotificationManagerCompat.from(context) + val notification = + NotificationCompat.Builder(context, NotificationManagerModule.UPDATES) + .setContentText(notificationContent) + .setContentTitle(notificationTitle) + .setAutoCancel(true) + .setSmallIcon(R.drawable.ic_app_updated_on) + .addAction( + NotificationCompat.Action( + R.drawable.ic_check_mark, + getString(android.R.string.ok), + PendingIntent.getActivity( + context, + uniqueActionId, + intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + PendingIntent.FLAG_IMMUTABLE + ) + ) + ) + .build() + notificationManager.notify(uniqueActionId, notification) + } + + GlobalScope.plus(Dispatchers.Default).launch { + EventBus.events.filterIsInstance() + .collectLatest { event -> + if (!isOnForeground()) { + sendNotification(event) + } + } + } } override val workManagerConfiguration: Configuration diff --git a/app/src/main/java/foundation/e/apps/install/pkg/InstallerService.kt b/app/src/main/java/foundation/e/apps/install/pkg/InstallerService.kt index 6bb4a9efd..d6239adb4 100644 --- a/app/src/main/java/foundation/e/apps/install/pkg/InstallerService.kt +++ b/app/src/main/java/foundation/e/apps/install/pkg/InstallerService.kt @@ -21,6 +21,8 @@ package foundation.e.apps.install.pkg import android.app.Service import android.content.Intent import android.content.pm.PackageInstaller +import android.content.pm.PackageManager +import android.os.Build import android.os.IBinder import dagger.hilt.android.AndroidEntryPoint import foundation.e.apps.data.application.UpdatesDao @@ -33,6 +35,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope import kotlinx.coroutines.launch import timber.log.Timber +import java.util.Optional import javax.inject.Inject @AndroidEntryPoint @@ -67,11 +70,58 @@ class InstallerService : Service() { the packageName from AppLoungePackageManager will be used in this error case. */ val packageNamePackageManagerModule = intent.getStringExtra(AppLoungePackageManager.PACKAGE_NAME) - + val startFlag = START_NOT_STICKY packageName = packageName ?: packageNamePackageManagerModule + + fun optionallyGetAppName(pkgName: String?): Optional { + if (pkgName.isNullOrBlank()) { + return Optional.empty() + } + return try { + val info = packageManager.getApplicationInfo(pkgName, 0) + val name = packageManager.getApplicationLabel(info) + Optional.of(name.toString()) + } catch (_: PackageManager.NameNotFoundException) { + Optional.empty() + } + } + + fun optionallyGetInstallerName(pkgName: String?): Optional { + if (pkgName.isNullOrBlank() || Build.VERSION.SDK_INT < Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + return Optional.empty() + } + return try { + val info = packageManager.getInstallSourceInfo(pkgName) + optionallyGetAppName(info.updateOwnerPackageName) + } catch (_: PackageManager.NameNotFoundException) { + Optional.empty() + } + } + + if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + val confirmationIntent = intent.getParcelableExtra( + Intent.EXTRA_INTENT, + Intent::class.java + ) + Timber.i(TAG, "need user input for $packageName") + if (confirmationIntent != null) { + MainScope().launch { + EventBus.invokeEvent( + AppEvent.NeedUserConfirmation( + confirmationIntent, + optionallyGetAppName(packageNamePackageManagerModule), + optionallyGetInstallerName(packageNamePackageManagerModule) + ) + ) + } + } + stopSelf() + return startFlag + } + postStatus(status, packageName, extra) stopSelf() - return START_NOT_STICKY + return startFlag } private fun postStatus(status: Int, packageName: String?, extra: 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 74e346889..e51d3c542 100644 --- a/app/src/main/java/foundation/e/apps/ui/MainActivity.kt +++ b/app/src/main/java/foundation/e/apps/ui/MainActivity.kt @@ -87,18 +87,18 @@ class MainActivity : AppCompatActivity() { private const val SESSION_DIALOG_TAG = "session_dialog" } - private val parentalControlAuthenticatorLauncher = registerForActivityResult( - ActivityResultContracts.StartActivityForResult() - ) { result -> - if (result.resultCode == RESULT_OK) { - val authenticationResult = result.data?.getBooleanExtra( - ParentalControlAuthenticator.KEY_PARENTAL_CONTROL_RESULT, false - ) == true - ParentalControlAuthenticator.setResult(authenticationResult) - } else { - ParentalControlAuthenticator.setResult(false) - } - } + private val parentalControlAuthenticatorLauncher = registerForActivityResult( + ActivityResultContracts.StartActivityForResult() + ) { result -> + if (result.resultCode == RESULT_OK) { + val authenticationResult = result.data?.getBooleanExtra( + ParentalControlAuthenticator.KEY_PARENTAL_CONTROL_RESULT, false + ) == true + ParentalControlAuthenticator.setResult(authenticationResult) + } else { + ParentalControlAuthenticator.setResult(false) + } + } override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -234,6 +234,10 @@ class MainActivity : AppCompatActivity() { observeAppUnavailable() } + launch { + observeNeedUserConformation() + } + launch { observeTooManyRequests() } @@ -524,6 +528,13 @@ class MainActivity : AppCompatActivity() { } } + private suspend fun observeNeedUserConformation() { + EventBus.events.filterIsInstance() + .collectLatest { event -> + startActivity(event.confirmation) + } + } + private fun validatedAuthObject(appEvent: AppEvent) { val data = appEvent.data as String if (data.isNotBlank()) { @@ -570,20 +581,20 @@ class MainActivity : AppCompatActivity() { private fun setupBottomNavItemSelectedListener( bottomNavigationView: BottomNavigationView, - navHostFragment: NavHostFragment, - navController: NavController - ) { - bottomNavigationView.setOnItemSelectedListener { - val fragment = navHostFragment.childFragmentManager.fragments - .find { fragment -> fragment is SettingsFragment } - if ( - bottomNavigationView.selectedItemId == R.id.settingsFragment && - fragment is SettingsFragment && - !fragment.isAnyAppSourceSelected() - ) { - ApplicationDialogFragment( - title = "", - message = getString(R.string.select_one_source_of_applications), + navHostFragment: NavHostFragment, + navController: NavController + ) { + bottomNavigationView.setOnItemSelectedListener { + val fragment = navHostFragment.childFragmentManager.fragments + .find { fragment -> fragment is SettingsFragment } + if ( + bottomNavigationView.selectedItemId == R.id.settingsFragment && + fragment is SettingsFragment && + !fragment.isAnyAppSourceSelected() + ) { + ApplicationDialogFragment( + title = "", + message = getString(R.string.select_one_source_of_applications), positiveButtonText = getString(R.string.ok) ).show(supportFragmentManager, TAG) return@setOnItemSelectedListener false diff --git a/app/src/main/java/foundation/e/apps/utils/eventBus/AppEvent.kt b/app/src/main/java/foundation/e/apps/utils/eventBus/AppEvent.kt index 8d5b38673..1d7188d84 100644 --- a/app/src/main/java/foundation/e/apps/utils/eventBus/AppEvent.kt +++ b/app/src/main/java/foundation/e/apps/utils/eventBus/AppEvent.kt @@ -20,16 +20,22 @@ package foundation.e.apps.utils.eventBus +import android.content.Intent import foundation.e.apps.data.ResultSupreme import foundation.e.apps.data.enums.ResultStatus import foundation.e.apps.data.enums.User import foundation.e.apps.data.install.models.AppInstall import kotlinx.coroutines.CompletableDeferred +import java.util.Optional sealed class AppEvent(val data: Any) { class SignatureMissMatchError(packageName: String) : AppEvent(packageName) class UpdateEvent(result: ResultSupreme.WorkError) : AppEvent(result) - + class NeedUserConfirmation( + val confirmation: Intent, + val appName: Optional, + val installerName: Optional + ) : AppEvent(confirmation) class InvalidAuthEvent(authName: String) : AppEvent(authName) class ErrorMessageEvent(stringResourceId: Int) : AppEvent(stringResourceId) class ErrorMessageDialogEvent(stringResourceId: Int) : AppEvent(stringResourceId) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3dcc7bf22..d841df8d2 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -233,4 +233,9 @@ App Unavailable This app is not available for installation. This is typically due to content restrictions based on your location (region) or the account\'s age settings (including age verification and age/content rating) + + the app + another app store + Approve update for %s + %s was installed from %s. Approval is required to update it. -- GitLab