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
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)
}
}

View File

@ -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)

View File

@ -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
)
}
}

View File

@ -219,11 +219,13 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
.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<DownloadService.Binder>() {
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)
}
}
}

View File

@ -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))
}
}