From 73ac718ce2c205938ea36647232d7fe88bc5a2a9 Mon Sep 17 00:00:00 2001 From: Matthew Crossman Date: Fri, 31 Dec 2021 10:41:45 +1100 Subject: [PATCH] Fix install prompt notification, further automate updates/installs. Fixes "tap to install" notification not launching an activity. Install notifications moved to InstallerService so that we only show them for apps that need intervention (and lets us preserve our install session). All installs go to the installer, allowing for downloads to jump straight into installation if possible (auto updates or showing prompt on A9 and earlier). If a prompt is needed, the notification is still shown. Additional utility function checks if app is in the foreground. Used to ensure that we do not launch a prompt that cannot be launched. Miscellaneous comments have been added and improved. --- .../com/looker/droidify/MainActivity.kt | 41 +++++++-- .../droidify/installer/DefaultInstaller.kt | 6 +- .../droidify/installer/InstallerService.kt | 92 +++++++++++++++---- .../droidify/service/DownloadService.kt | 18 ++-- .../com/looker/droidify/utility/Utils.kt | 14 +++ 5 files changed, 135 insertions(+), 36 deletions(-) diff --git a/src/main/kotlin/com/looker/droidify/MainActivity.kt b/src/main/kotlin/com/looker/droidify/MainActivity.kt index 87414d42..897becd1 100644 --- a/src/main/kotlin/com/looker/droidify/MainActivity.kt +++ b/src/main/kotlin/com/looker/droidify/MainActivity.kt @@ -1,9 +1,15 @@ package com.looker.droidify +import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.content.pm.PackageInstaller +import android.net.Uri import com.looker.droidify.ContextWrapperX.Companion.wrap +import com.looker.droidify.installer.InstallerService import com.looker.droidify.screen.ScreenActivity +import com.looker.droidify.utility.extension.android.Android +import kotlinx.coroutines.withContext class MainActivity : ScreenActivity() { companion object { @@ -16,12 +22,35 @@ class MainActivity : ScreenActivity() { override fun handleIntent(intent: Intent?) { when (intent?.action) { ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates) - ACTION_INSTALL -> handleSpecialIntent( - SpecialIntent.Install( - intent.packageName, - intent.getStringExtra(EXTRA_CACHE_FILE_NAME) - ) - ) + ACTION_INSTALL -> { + // continue install prompt + val promptIntent: Intent? = intent.getParcelableExtra(Intent.EXTRA_INTENT) + + promptIntent?.let { + it.putExtra(Intent.EXTRA_NOT_UNKNOWN_SOURCE, true) + it.putExtra(Intent.EXTRA_INSTALLER_PACKAGE_NAME, BuildConfig.APPLICATION_ID) + it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK ) + + startActivity(it) + } + + // TODO: send this back to the InstallerService to free up the UI + // prepare prompt intent +// val name = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) +// +// val pending = PendingIntent.getService( +// this, +// 0, +// Intent(this, InstallerService::class.java) +// .setData(Uri.parse("package:$name")) +// .putExtra(Intent.EXTRA_INTENT, promptIntent) +// .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), +// if (Android.sdk(23)) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE +// else PendingIntent.FLAG_UPDATE_CURRENT +// ) +// +// pending.send() + } else -> super.handleIntent(intent) } } diff --git a/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt b/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt index 00819462..d637b3a9 100644 --- a/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt +++ b/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt @@ -33,11 +33,15 @@ class DefaultInstaller(context: Context) : BaseInstaller(context) { override suspend fun install(packageName: String, cacheFileName: String) { val cacheFile = Cache.getReleaseFile(context, cacheFileName) + // using packageName to store the app's name for the notification later down the line + intent.putExtra(InstallerService.KEY_APP_NAME, packageName) mDefaultInstaller(cacheFile) } - override suspend fun install(packageName: String, cacheFile: File) = + override suspend fun install(packageName: String, cacheFile: File) { + intent.putExtra(InstallerService.KEY_APP_NAME, packageName) mDefaultInstaller(cacheFile) + } override suspend fun uninstall(packageName: String) = mDefaultUninstaller(packageName) diff --git a/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt b/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt index 0a6e49ee..6946e8a2 100644 --- a/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt +++ b/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt @@ -1,32 +1,41 @@ package com.looker.droidify.installer +import android.app.PendingIntent import android.app.Service import android.content.Intent import android.content.pm.PackageInstaller import android.content.pm.PackageManager +import android.net.Uri import android.os.IBinder import android.view.ContextThemeWrapper import androidx.core.app.NotificationCompat -import com.looker.droidify.Common +import com.looker.droidify.Common.NOTIFICATION_CHANNEL_DOWNLOADING +import com.looker.droidify.Common.NOTIFICATION_ID_DOWNLOADING import com.looker.droidify.R +import com.looker.droidify.MainActivity +import com.looker.droidify.utility.Utils +import com.looker.droidify.utility.extension.android.Android import com.looker.droidify.utility.extension.android.notificationManager import com.looker.droidify.utility.extension.resources.getColorFromAttr /** * Runs during or after a PackageInstaller session in order to handle completion, failure, or - * interruptions requiring user intervention (e.g. "Install Unknown Apps" permission requests). + * interruptions requiring user intervention, such as the package installer prompt. */ class InstallerService : Service() { companion object { const val KEY_ACTION = "installerAction" + const val KEY_APP_NAME = "appName" const val ACTION_UNINSTALL = "uninstall" } override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) - if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { - // prompts user to enable unknown source + // only trigger a prompt if in foreground or below Android 10, otherwise make notification + // launching a prompt in the background will fail silently + if ((!Android.sdk(29) || Utils.inForeground()) && status == PackageInstaller.STATUS_PENDING_USER_ACTION) { + // Triggers the installer prompt and "unknown apps" prompt if needed val promptIntent: Intent? = intent.getParcelableExtra(Intent.EXTRA_INTENT) promptIntent?.let { @@ -45,7 +54,10 @@ class InstallerService : Service() { } /** - * Notifies user of installer outcome. + * Notifies user of installer outcome. This can be success, error, or a request for user action + * if installation cannot proceed automatically. + * + * @param intent provided by PackageInstaller to the callback service/activity. */ private fun notifyStatus(intent: Intent) { // unpack from intent @@ -55,22 +67,23 @@ class InstallerService : Service() { val installerAction = intent.getStringExtra(KEY_ACTION) // get application name for notifications - val appLabel = try { - if (name != null) packageManager.getApplicationLabel( - packageManager.getApplicationInfo( - name, - PackageManager.GET_META_DATA - ) - ) else null - } catch (_: Exception) { - null - } + val appLabel = intent.getStringExtra(KEY_APP_NAME) + ?: try { + if (name != null) packageManager.getApplicationLabel( + packageManager.getApplicationInfo( + name, + PackageManager.GET_META_DATA + ) + ) else null + } catch (_: Exception) { + null + } val notificationTag = "download-$name" // start building val builder = NotificationCompat - .Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING) + .Builder(this, NOTIFICATION_CHANNEL_DOWNLOADING) .setAutoCancel(true) .setColor( ContextThemeWrapper(this, R.style.Theme_Main_Light) @@ -78,10 +91,21 @@ class InstallerService : Service() { ) when (status) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + // request user action with "downloaded" notification that triggers a working prompt + notificationManager.notify( + notificationTag, NOTIFICATION_ID_DOWNLOADING, builder + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentIntent(installIntent(intent)) + .setContentTitle(getString(R.string.downloaded_FORMAT, appLabel)) + .setContentText(getString(R.string.tap_to_install_DESC)) + .build() + ) + } PackageInstaller.STATUS_SUCCESS -> { if (installerAction == ACTION_UNINSTALL) // remove any notification for this app - notificationManager.cancel(notificationTag, Common.NOTIFICATION_ID_DOWNLOADING) + notificationManager.cancel(notificationTag, NOTIFICATION_ID_DOWNLOADING) else { val notification = builder .setSmallIcon(android.R.drawable.stat_sys_download_done) @@ -90,11 +114,11 @@ class InstallerService : Service() { .build() notificationManager.notify( notificationTag, - Common.NOTIFICATION_ID_DOWNLOADING, + NOTIFICATION_ID_DOWNLOADING, notification ) Thread.sleep(5000) - notificationManager.cancel(notificationTag, Common.NOTIFICATION_ID_DOWNLOADING) + notificationManager.cancel(notificationTag, NOTIFICATION_ID_DOWNLOADING) } } PackageInstaller.STATUS_FAILURE_ABORTED -> { @@ -109,7 +133,7 @@ class InstallerService : Service() { .build() notificationManager.notify( notificationTag, - Common.NOTIFICATION_ID_DOWNLOADING, + NOTIFICATION_ID_DOWNLOADING, notification ) } @@ -120,5 +144,33 @@ class InstallerService : Service() { return null } + /** + * Generates an intent that provides the specified activity information necessary to trigger + * the package manager's prompt, thus completing a staged installation requiring user + * intervention. + * + * @param intent the intent provided by PackageInstaller to the callback target passed to + * PackageInstaller.Session.commit(). + * @return a pending intent that can be attached to a background-accessible entry point such as + * a notification + */ + private fun installIntent(intent: Intent): PendingIntent { + // prepare prompt intent + val promptIntent : Intent? = intent.getParcelableExtra(Intent.EXTRA_INTENT) + val name = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME) + + return PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java) + .setAction(MainActivity.ACTION_INSTALL) + .setData(Uri.parse("package:$name")) + .putExtra(Intent.EXTRA_INTENT, promptIntent) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), + if (Android.sdk(23)) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + else PendingIntent.FLAG_UPDATE_CURRENT + ) + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/looker/droidify/service/DownloadService.kt b/src/main/kotlin/com/looker/droidify/service/DownloadService.kt index 27db7c13..f045bd38 100644 --- a/src/main/kotlin/com/looker/droidify/service/DownloadService.kt +++ b/src/main/kotlin/com/looker/droidify/service/DownloadService.kt @@ -219,11 +219,13 @@ class DownloadService : ConnectionService() { .getColorFromAttr(R.attr.colorPrimary).defaultColor ) .setContentIntent( - PendingIntent.getBroadcast( + PendingIntent.getActivity( this, 0, - Intent(this, Receiver::class.java) - .setAction("$ACTION_OPEN.${task.packageName}"), + Intent(this, MainActivity::class.java) + .setAction(Intent.ACTION_VIEW) + .setData(Uri.parse("package:${task.packageName}")) + .setFlags(Intent.FLAG_ACTIVITY_NEW_TASK), if (Android.sdk(23)) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE else @@ -308,12 +310,10 @@ class DownloadService : ConnectionService() { consumed = true } if (!consumed) { - if (rootInstallerEnabled) { - scope.launch { - AppInstaller.getInstance(this@DownloadService) - ?.defaultInstaller?.install(task.release.cacheFileName) - } - } else showNotificationInstall(task) + scope.launch { + AppInstaller.getInstance(this@DownloadService) + ?.defaultInstaller?.install(task.name, task.release.cacheFileName) + } } } diff --git a/src/main/kotlin/com/looker/droidify/utility/Utils.kt b/src/main/kotlin/com/looker/droidify/utility/Utils.kt index 58e42d43..fff8f177 100644 --- a/src/main/kotlin/com/looker/droidify/utility/Utils.kt +++ b/src/main/kotlin/com/looker/droidify/utility/Utils.kt @@ -1,5 +1,8 @@ package com.looker.droidify.utility +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE import android.content.Context import android.content.pm.PackageInfo import android.content.pm.Signature @@ -166,4 +169,15 @@ object Utils { ) else -> Locale(localeCode) } + + /** + * Checks if app is currently considered to be in the foreground by Android. + */ + fun inForeground(): Boolean { + val appProcessInfo = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(appProcessInfo) + val importance = appProcessInfo.importance + return ((importance == IMPORTANCE_FOREGROUND) or (importance == IMPORTANCE_VISIBLE)) + } + }