Improve install notifications, improve DefaultInstaller, misc. clean-up

Installer notifications have their own channel, their tags have been
fixed, and the timeout has been properly set instead of using sleep.

Ensured that DefaultInstaller's sessions use unique file names. Also
improved error handling by including broken pipes and by preventing
post-copy operations if an error has occurred.

Some cleaning up has been done in Common, DownloadService, and
SyncService. A few changes have been cherry-picked from master.
This commit is contained in:
Matthew Crossman 2022-01-04 20:26:07 +11:00
parent e1fc3c656a
commit 375ab23edb
No known key found for this signature in database
GPG Key ID: C6B942B019794CC2
6 changed files with 112 additions and 117 deletions

View File

@ -4,10 +4,12 @@ object Common {
const val NOTIFICATION_CHANNEL_SYNCING = "syncing"
const val NOTIFICATION_CHANNEL_UPDATES = "updates"
const val NOTIFICATION_CHANNEL_DOWNLOADING = "downloading"
const val NOTIFICATION_CHANNEL_INSTALLER = "installed"
const val NOTIFICATION_ID_SYNCING = 1
const val NOTIFICATION_ID_UPDATES = 2
const val NOTIFICATION_ID_DOWNLOADING = 3
const val NOTIFICATION_ID_INSTALLER = 4
const val PREFS_LANGUAGE = "languages"
const val PREFS_LANGUAGE_DEFAULT = "system"

View File

@ -11,10 +11,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileNotFoundException
import java.io.IOException
class DefaultInstaller(context: Context) : BaseInstaller(context) {
private val sessionInstaller = context.packageManager.packageInstaller
private val packageManager = context.packageManager
private val sessionInstaller = packageManager.packageInstaller
private val intent = Intent(context, InstallerService::class.java)
companion object {
@ -48,27 +50,48 @@ class DefaultInstaller(context: Context) : BaseInstaller(context) {
override suspend fun uninstall(packageName: String) = mDefaultUninstaller(packageName)
private fun mDefaultInstaller(cacheFile: File) {
// clean up inactive sessions
sessionInstaller.mySessions
.filter { session -> !session.isActive }
.forEach { session -> sessionInstaller.abandonSession(session.sessionId) }
// start new session
val id = sessionInstaller.createSession(sessionParams)
val session = sessionInstaller.openSession(id)
// get package name
val packageInfo = packageManager.getPackageArchiveInfo(cacheFile.absolutePath, 0)
val packageName = packageInfo?.packageName ?: "unknown-package"
// error flags
var hasErrors = false
session.use { activeSession ->
activeSession.openWrite("package", 0, cacheFile.length()).use { packageStream ->
activeSession.openWrite(packageName, 0, cacheFile.length()).use { packageStream ->
try {
cacheFile.inputStream().use { fileStream ->
fileStream.copyTo(packageStream)
}
} catch (error: FileNotFoundException) {
Log.w("DefaultInstaller", "Cache file for DefaultInstaller does not seem to exist.")
} catch (e: FileNotFoundException) {
Log.w(
"DefaultInstaller",
"Cache file for DefaultInstaller does not seem to exist."
)
hasErrors = true
} catch (e: IOException) {
Log.w(
"DefaultInstaller",
"Failed to perform cache to package copy due to a bad pipe."
)
hasErrors = true
}
}
val pendingIntent = PendingIntent.getService(context, id, intent, flags)
session.commit(pendingIntent.intentSender)
}
cacheFile.delete()
if (!hasErrors) {
session.commit(PendingIntent.getService(context, id, intent, flags).intentSender)
cacheFile.delete()
}
}
private suspend fun mDefaultUninstaller(packageName: String) {

View File

@ -1,16 +1,17 @@
package com.looker.droidify.installer
import android.app.NotificationChannel
import android.app.NotificationManager
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.NOTIFICATION_CHANNEL_DOWNLOADING
import com.looker.droidify.Common.NOTIFICATION_ID_DOWNLOADING
import com.looker.droidify.Common.NOTIFICATION_CHANNEL_INSTALLER
import com.looker.droidify.Common.NOTIFICATION_ID_INSTALLER
import com.looker.droidify.R
import com.looker.droidify.MainActivity
import com.looker.droidify.utility.Utils
@ -27,6 +28,20 @@ class InstallerService : Service() {
const val KEY_ACTION = "installerAction"
const val KEY_APP_NAME = "appName"
const val ACTION_UNINSTALL = "uninstall"
private const val INSTALLED_NOTIFICATION_TIMEOUT: Long = 10000
private const val NOTIFICATION_TAG_PREFIX = "install-"
}
override fun onCreate() {
super.onCreate()
if (Android.sdk(26)) {
NotificationChannel(
NOTIFICATION_CHANNEL_INSTALLER,
getString(R.string.syncing), NotificationManager.IMPORTANCE_LOW
)
.let(notificationManager::createNotificationChannel)
}
}
override fun onStartCommand(intent: Intent, flags: Int, startId: Int): Int {
@ -61,12 +76,18 @@ class InstallerService : Service() {
private fun notifyStatus(intent: Intent) {
// unpack from intent
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
val name = intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
val sessionId = intent.getIntExtra(PackageInstaller.EXTRA_SESSION_ID, -1)
// get package information from session
val sessionInstaller = this.packageManager.packageInstaller
val session = if (sessionId > 0) sessionInstaller.getSessionInfo(sessionId) else null
val name = session?.appPackageName ?: intent.getStringExtra(PackageInstaller.EXTRA_PACKAGE_NAME)
val message = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
val installerAction = intent.getStringExtra(KEY_ACTION)
// get application name for notifications
val appLabel = intent.getStringExtra(KEY_APP_NAME)
val appLabel = session?.appLabel ?: intent.getStringExtra(KEY_APP_NAME)
?: try {
if (name != null) packageManager.getApplicationLabel(
packageManager.getApplicationInfo(
@ -78,11 +99,11 @@ class InstallerService : Service() {
null
}
val notificationTag = "download-$name"
val notificationTag = "${NOTIFICATION_TAG_PREFIX}$name"
// start building
val builder = NotificationCompat
.Builder(this, NOTIFICATION_CHANNEL_DOWNLOADING)
.Builder(this, NOTIFICATION_CHANNEL_INSTALLER)
.setAutoCancel(true)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
@ -93,7 +114,7 @@ class InstallerService : Service() {
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
// request user action with "downloaded" notification that triggers a working prompt
notificationManager.notify(
notificationTag, NOTIFICATION_ID_DOWNLOADING, builder
notificationTag, NOTIFICATION_ID_INSTALLER, builder
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentIntent(installIntent(intent))
.setContentTitle(getString(R.string.downloaded_FORMAT, appLabel))
@ -104,20 +125,19 @@ class InstallerService : Service() {
PackageInstaller.STATUS_SUCCESS -> {
if (installerAction == ACTION_UNINSTALL)
// remove any notification for this app
notificationManager.cancel(notificationTag, NOTIFICATION_ID_DOWNLOADING)
notificationManager.cancel(notificationTag, NOTIFICATION_ID_INSTALLER)
else {
val notification = builder
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setContentTitle(getString(R.string.installed))
.setContentText(appLabel)
.setTimeoutAfter(INSTALLED_NOTIFICATION_TIMEOUT)
.build()
notificationManager.notify(
notificationTag,
NOTIFICATION_ID_DOWNLOADING,
NOTIFICATION_ID_INSTALLER,
notification
)
Thread.sleep(5000)
notificationManager.cancel(notificationTag, NOTIFICATION_ID_DOWNLOADING)
}
}
PackageInstaller.STATUS_FAILURE_ABORTED -> {
@ -132,7 +152,7 @@ class InstallerService : Service() {
.build()
notificationManager.notify(
notificationTag,
NOTIFICATION_ID_DOWNLOADING,
NOTIFICATION_ID_INSTALLER,
notification
)
}
@ -163,7 +183,6 @@ class InstallerService : Service() {
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

View File

@ -41,6 +41,11 @@ class SettingsFragment : ScreenFragment() {
private var preferenceBinding: PreferenceItemBinding? = null
private val preferences = mutableMapOf<Preferences.Key<*>, Preference<*>>()
override fun onResume() {
super.onResume()
preferences.forEach { (_, preference) -> preference.update() }
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
preferenceBinding = PreferenceItemBinding.inflate(layoutInflater)
@ -216,7 +221,7 @@ class SettingsFragment : ScreenFragment() {
}
}
private fun LinearLayoutCompat.addCategory(
private inline fun LinearLayoutCompat.addCategory(
title: String,
callback: LinearLayoutCompat.() -> Unit,
) {

View File

@ -3,14 +3,14 @@ package com.looker.droidify.service
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
import com.looker.droidify.BuildConfig
import com.looker.droidify.Common
import com.looker.droidify.Common.NOTIFICATION_ID_DOWNLOADING
import com.looker.droidify.Common.NOTIFICATION_ID_SYNCING
import com.looker.droidify.Common.NOTIFICATION_CHANNEL_DOWNLOADING
import com.looker.droidify.MainActivity
import com.looker.droidify.R
import com.looker.droidify.content.Cache
@ -19,61 +19,27 @@ import com.looker.droidify.entity.Repository
import com.looker.droidify.installer.AppInstaller
import com.looker.droidify.network.Downloader
import com.looker.droidify.utility.Utils
import com.looker.droidify.utility.Utils.rootInstallerEnabled
import com.looker.droidify.utility.extension.android.*
import com.looker.droidify.utility.extension.resources.*
import com.looker.droidify.utility.extension.text.*
import io.reactivex.rxjava3.android.schedulers.AndroidSchedulers
import io.reactivex.rxjava3.disposables.Disposable
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.*
import java.io.File
import java.security.MessageDigest
import kotlin.math.*
class DownloadService : ConnectionService<DownloadService.Binder>() {
companion object {
private const val ACTION_OPEN = "${BuildConfig.APPLICATION_ID}.intent.action.OPEN"
private const val ACTION_INSTALL = "${BuildConfig.APPLICATION_ID}.intent.action.INSTALL"
private const val ACTION_CANCEL = "${BuildConfig.APPLICATION_ID}.intent.action.CANCEL"
private const val EXTRA_CACHE_FILE_NAME =
"${BuildConfig.APPLICATION_ID}.intent.extra.CACHE_FILE_NAME"
private val mutableDownloadState = MutableSharedFlow<State.Downloading>()
private val downloadState = mutableDownloadState.asSharedFlow()
}
val scope = CoroutineScope(Dispatchers.Default)
class Receiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent) {
val action = intent.action.orEmpty()
when {
action.startsWith("$ACTION_OPEN.") -> {
val packageName = action.substring(ACTION_OPEN.length + 1)
context.startActivity(
Intent(context, MainActivity::class.java)
.setAction(Intent.ACTION_VIEW)
.setData(Uri.parse("package:$packageName"))
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
action.startsWith("$ACTION_INSTALL.") -> {
val packageName = action.substring(ACTION_INSTALL.length + 1)
val cacheFileName = intent.getStringExtra(EXTRA_CACHE_FILE_NAME)
context.startActivity(
Intent(context, MainActivity::class.java)
.setAction(MainActivity.ACTION_INSTALL)
.setData(Uri.parse("package:$packageName"))
.putExtra(MainActivity.EXTRA_CACHE_FILE_NAME, cacheFileName)
.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
}
}
}
}
private val scope = CoroutineScope(Dispatchers.Default)
private val mainDispatcher = Dispatchers.Main
sealed class State(val packageName: String, val name: String) {
class Pending(packageName: String, name: String) : State(packageName, name)
@ -121,7 +87,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
} else {
cancelTasks(packageName)
cancelCurrentTask(packageName)
notificationManager.cancel(task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING)
notificationManager.cancel(task.notificationTag, NOTIFICATION_ID_DOWNLOADING)
tasks += task
if (currentTask == null) {
handleDownload()
@ -146,16 +112,14 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
if (Android.sdk(26)) {
NotificationChannel(
Common.NOTIFICATION_CHANNEL_DOWNLOADING,
NOTIFICATION_CHANNEL_DOWNLOADING,
getString(R.string.downloading), NotificationManager.IMPORTANCE_LOW
)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
}
scope.launch {
downloadState.collect { publishForegroundState(false, it) }
}
downloadState.onEach { publishForegroundState(false, it) }.launchIn(scope)
}
override fun onDestroy() {
@ -176,7 +140,14 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
private fun cancelTasks(packageName: String?) {
tasks.removeAll {
(packageName == null || it.packageName == packageName) && run {
scope.launch { mutableStateSubject.emit(State.Cancel(it.packageName, it.name)) }
scope.launch(mainDispatcher) {
mutableStateSubject.emit(
State.Cancel(
it.packageName,
it.name
)
)
}
true
}
}
@ -186,7 +157,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
currentTask?.let {
if (packageName == null || it.task.packageName == packageName) {
currentTask = null
scope.launch {
scope.launch(mainDispatcher) {
mutableStateSubject.emit(
State.Cancel(
it.task.packageName,
@ -209,9 +180,9 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
private fun showNotificationError(task: Task, errorType: ErrorType) {
notificationManager.notify(task.notificationTag,
Common.NOTIFICATION_ID_DOWNLOADING,
NOTIFICATION_ID_DOWNLOADING,
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.Builder(this, NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(
@ -276,36 +247,9 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
.build())
}
private fun showNotificationInstall(task: Task) {
notificationManager.notify(
task.notificationTag, Common.NOTIFICATION_ID_DOWNLOADING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.setAutoCancel(true)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
.getColorFromAttr(R.attr.colorPrimary).defaultColor
)
.setContentIntent(installIntent(task))
.setContentTitle(getString(R.string.downloaded_FORMAT, task.name))
.setContentText(getString(R.string.tap_to_install_DESC))
.build()
)
}
private fun installIntent(task: Task): PendingIntent = PendingIntent.getBroadcast(
this,
0,
Intent(this, Receiver::class.java)
.setAction("$ACTION_INSTALL.${task.packageName}")
.putExtra(EXTRA_CACHE_FILE_NAME, task.release.cacheFileName),
if (Android.sdk(23)) PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
else PendingIntent.FLAG_UPDATE_CURRENT
)
private fun publishSuccess(task: Task) {
var consumed = false
scope.launch {
scope.launch(mainDispatcher) {
mutableStateSubject.emit(State.Success(task.packageName, task.name, task.release))
consumed = true
}
@ -367,7 +311,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
private val stateNotificationBuilder by lazy {
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_DOWNLOADING)
.Builder(this, NOTIFICATION_CHANNEL_DOWNLOADING)
.setSmallIcon(android.R.drawable.stat_sys_download)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
@ -389,7 +333,7 @@ class DownloadService : ConnectionService<DownloadService.Binder>() {
private fun publishForegroundState(force: Boolean, state: State) {
if (force || currentTask != null) {
currentTask = currentTask?.copy(lastState = state)
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
startForeground(NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.downloading_FORMAT, state.name))

View File

@ -7,14 +7,16 @@ import android.app.job.JobParameters
import android.app.job.JobService
import android.content.Intent
import android.graphics.Color
import android.os.Build
import android.text.SpannableStringBuilder
import android.text.style.ForegroundColorSpan
import android.view.ContextThemeWrapper
import androidx.core.app.NotificationCompat
import androidx.fragment.app.Fragment
import com.looker.droidify.BuildConfig
import com.looker.droidify.Common
import com.looker.droidify.Common.NOTIFICATION_ID_UPDATES
import com.looker.droidify.Common.NOTIFICATION_ID_SYNCING
import com.looker.droidify.Common.NOTIFICATION_CHANNEL_SYNCING
import com.looker.droidify.Common.NOTIFICATION_CHANNEL_UPDATES
import com.looker.droidify.MainActivity
import com.looker.droidify.R
import com.looker.droidify.content.Preferences
@ -123,7 +125,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
fun setUpdateNotificationBlocker(fragment: Fragment?) {
updateNotificationBlockerFragment = fragment?.let(::WeakReference)
if (fragment != null) {
notificationManager.cancel(Common.NOTIFICATION_ID_UPDATES)
notificationManager.cancel(NOTIFICATION_ID_UPDATES)
}
}
@ -164,13 +166,13 @@ class SyncService : ConnectionService<SyncService.Binder>() {
if (Android.sdk(26)) {
NotificationChannel(
Common.NOTIFICATION_CHANNEL_SYNCING,
NOTIFICATION_CHANNEL_SYNCING,
getString(R.string.syncing), NotificationManager.IMPORTANCE_LOW
)
.apply { setShowBadge(false) }
.let(notificationManager::createNotificationChannel)
NotificationChannel(
Common.NOTIFICATION_CHANNEL_UPDATES,
NOTIFICATION_CHANNEL_UPDATES,
getString(R.string.updates), NotificationManager.IMPORTANCE_LOW
)
.let(notificationManager::createNotificationChannel)
@ -215,8 +217,8 @@ class SyncService : ConnectionService<SyncService.Binder>() {
private fun showNotificationError(repository: Repository, exception: Exception) {
notificationManager.notify(
"repository-${repository.id}", Common.NOTIFICATION_ID_SYNCING, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
"repository-${repository.id}", NOTIFICATION_ID_SYNCING, NotificationCompat
.Builder(this, NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
@ -242,7 +244,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
private val stateNotificationBuilder by lazy {
NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_SYNCING)
.Builder(this, NOTIFICATION_CHANNEL_SYNCING)
.setSmallIcon(R.drawable.ic_sync)
.setColor(
ContextThemeWrapper(this, R.style.Theme_Main_Light)
@ -253,7 +255,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
this,
0,
Intent(this, this::class.java).setAction(ACTION_CANCEL),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
if (Android.sdk(23))
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
else
PendingIntent.FLAG_UPDATE_CURRENT
@ -265,7 +267,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
if (force || currentTask?.lastState != state) {
currentTask = currentTask?.copy(lastState = state)
if (started == Started.MANUAL) {
startForeground(Common.NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
startForeground(NOTIFICATION_ID_SYNCING, stateNotificationBuilder.apply {
when (state) {
is State.Connecting -> {
setContentTitle(getString(R.string.syncing_FORMAT, state.name))
@ -474,8 +476,8 @@ class SyncService : ConnectionService<SyncService.Binder>() {
val maxUpdates = 5
fun <T> T.applyHack(callback: T.() -> Unit): T = apply(callback)
notificationManager.notify(
Common.NOTIFICATION_ID_UPDATES, NotificationCompat
.Builder(this, Common.NOTIFICATION_CHANNEL_UPDATES)
NOTIFICATION_ID_UPDATES, NotificationCompat
.Builder(this, NOTIFICATION_CHANNEL_UPDATES)
.setSmallIcon(R.drawable.ic_new_releases)
.setContentTitle(getString(R.string.new_updates_available))
.setContentText(
@ -494,7 +496,7 @@ class SyncService : ConnectionService<SyncService.Binder>() {
0,
Intent(this, MainActivity::class.java)
.setAction(MainActivity.ACTION_UPDATES),
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M)
if (Android.sdk(23))
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
else
PendingIntent.FLAG_UPDATE_CURRENT