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.
This commit is contained in:
Matthew Crossman 2021-12-31 10:41:45 +11:00
parent ddb7422e45
commit 73ac718ce2
No known key found for this signature in database
GPG Key ID: C6B942B019794CC2
5 changed files with 135 additions and 36 deletions

View File

@ -1,9 +1,15 @@
package com.looker.droidify package com.looker.droidify
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller
import android.net.Uri
import com.looker.droidify.ContextWrapperX.Companion.wrap import com.looker.droidify.ContextWrapperX.Companion.wrap
import com.looker.droidify.installer.InstallerService
import com.looker.droidify.screen.ScreenActivity import com.looker.droidify.screen.ScreenActivity
import com.looker.droidify.utility.extension.android.Android
import kotlinx.coroutines.withContext
class MainActivity : ScreenActivity() { class MainActivity : ScreenActivity() {
companion object { companion object {
@ -16,12 +22,35 @@ class MainActivity : ScreenActivity() {
override fun handleIntent(intent: Intent?) { override fun handleIntent(intent: Intent?) {
when (intent?.action) { when (intent?.action) {
ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates) ACTION_UPDATES -> handleSpecialIntent(SpecialIntent.Updates)
ACTION_INSTALL -> handleSpecialIntent( ACTION_INSTALL -> {
SpecialIntent.Install( // continue install prompt
intent.packageName, val promptIntent: Intent? = intent.getParcelableExtra(Intent.EXTRA_INTENT)
intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
) 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) else -> super.handleIntent(intent)
} }
} }

View File

@ -33,11 +33,15 @@ class DefaultInstaller(context: Context) : BaseInstaller(context) {
override suspend fun install(packageName: String, cacheFileName: String) { override suspend fun install(packageName: String, cacheFileName: String) {
val cacheFile = Cache.getReleaseFile(context, cacheFileName) 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) 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) mDefaultInstaller(cacheFile)
}
override suspend fun uninstall(packageName: String) = mDefaultUninstaller(packageName) override suspend fun uninstall(packageName: String) = mDefaultUninstaller(packageName)

View File

@ -1,32 +1,41 @@
package com.looker.droidify.installer package com.looker.droidify.installer
import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.IBinder import android.os.IBinder
import android.view.ContextThemeWrapper import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat 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.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.android.notificationManager
import com.looker.droidify.utility.extension.resources.getColorFromAttr import com.looker.droidify.utility.extension.resources.getColorFromAttr
/** /**
* Runs during or after a PackageInstaller session in order to handle completion, failure, or * 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() { class InstallerService : Service() {
companion object { companion object {
const val KEY_ACTION = "installerAction" const val KEY_ACTION = "installerAction"
const val KEY_APP_NAME = "appName"
const val ACTION_UNINSTALL = "uninstall" const val ACTION_UNINSTALL = "uninstall"
} }
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
if (status == PackageInstaller.STATUS_PENDING_USER_ACTION) { // only trigger a prompt if in foreground or below Android 10, otherwise make notification
// prompts user to enable unknown source // 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) val promptIntent: Intent? = intent.getParcelableExtra(Intent.EXTRA_INTENT)
promptIntent?.let { 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) { private fun notifyStatus(intent: Intent) {
// unpack from intent // unpack from intent
@ -55,7 +67,8 @@ class InstallerService : Service() {
val installerAction = intent.getStringExtra(KEY_ACTION) val installerAction = intent.getStringExtra(KEY_ACTION)
// get application name for notifications // get application name for notifications
val appLabel = try { val appLabel = intent.getStringExtra(KEY_APP_NAME)
?: try {
if (name != null) packageManager.getApplicationLabel( if (name != null) packageManager.getApplicationLabel(
packageManager.getApplicationInfo( packageManager.getApplicationInfo(
name, name,
@ -70,7 +83,7 @@ class InstallerService : Service() {
// start building // start building
val builder = NotificationCompat val builder = NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING) .Builder(this, NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true) .setAutoCancel(true)
.setColor( .setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light) ContextThemeWrapper(this, R.style.Theme_Main_Light)
@ -78,10 +91,21 @@ class InstallerService : Service() {
) )
when (status) { 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 -> { PackageInstaller.STATUS_SUCCESS -> {
if (installerAction == ACTION_UNINSTALL) if (installerAction == ACTION_UNINSTALL)
// remove any notification for this app // remove any notification for this app
notificationManager.cancel(notificationTag, Common.NOTIFICATION_ID_DOWNLOADING) notificationManager.cancel(notificationTag, NOTIFICATION_ID_DOWNLOADING)
else { else {
val notification = builder val notification = builder
.setSmallIcon(android.R.drawable.stat_sys_download_done) .setSmallIcon(android.R.drawable.stat_sys_download_done)
@ -90,11 +114,11 @@ class InstallerService : Service() {
.build() .build()
notificationManager.notify( notificationManager.notify(
notificationTag, notificationTag,
Common.NOTIFICATION_ID_DOWNLOADING, NOTIFICATION_ID_DOWNLOADING,
notification notification
) )
Thread.sleep(5000) Thread.sleep(5000)
notificationManager.cancel(notificationTag, Common.NOTIFICATION_ID_DOWNLOADING) notificationManager.cancel(notificationTag, NOTIFICATION_ID_DOWNLOADING)
} }
} }
PackageInstaller.STATUS_FAILURE_ABORTED -> { PackageInstaller.STATUS_FAILURE_ABORTED -> {
@ -109,7 +133,7 @@ class InstallerService : Service() {
.build() .build()
notificationManager.notify( notificationManager.notify(
notificationTag, notificationTag,
Common.NOTIFICATION_ID_DOWNLOADING, NOTIFICATION_ID_DOWNLOADING,
notification notification
) )
} }
@ -120,5 +144,33 @@ class InstallerService : Service() {
return null 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
)
}
} }

View File

@ -219,11 +219,13 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
.getColorFromAttr(R.attr.colorPrimary).defaultColor .getColorFromAttr(R.attr.colorPrimary).defaultColor
) )
.setContentIntent( .setContentIntent(
PendingIntent.getBroadcast( PendingIntent.getActivity(
this, this,
0, 0,
Intent(this, Receiver::class.java) Intent(this, MainActivity::class.java)
.setAction("$ACTION_OPEN.${task.packageName}"), .setAction(Intent.ACTION_VIEW)
.setData(Uri.parse("package:${task.packageName}"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK),
if (Android.sdk(23)) if (Android.sdk(23))
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
else else
@ -308,12 +310,10 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
consumed = true consumed = true
} }
if (!consumed) { if (!consumed) {
if (rootInstallerEnabled) {
scope.launch { scope.launch {
AppInstaller.getInstance(this@DownloadService) AppInstaller.getInstance(this@DownloadService)
?.defaultInstaller?.install(task.release.cacheFileName) ?.defaultInstaller?.install(task.name, task.release.cacheFileName)
} }
} else showNotificationInstall(task)
} }
} }

View File

@ -1,5 +1,8 @@
package com.looker.droidify.utility 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.Context
import android.content.pm.PackageInfo import android.content.pm.PackageInfo
import android.content.pm.Signature import android.content.pm.Signature
@ -166,4 +169,15 @@ object Utils {
) )
else -> Locale(localeCode) 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))
}
} }