diff --git a/src/main/kotlin/com/looker/droidify/Common.kt b/src/main/kotlin/com/looker/droidify/Common.kt index 446a2f83..a6bc57c0 100644 --- a/src/main/kotlin/com/looker/droidify/Common.kt +++ b/src/main/kotlin/com/looker/droidify/Common.kt @@ -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" diff --git a/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt b/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt index a600ab09..acbf2c9a 100644 --- a/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt +++ b/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt @@ -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) { diff --git a/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt b/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt index 2be4a9e8..306c2ce0 100644 --- a/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt +++ b/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt @@ -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 diff --git a/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt b/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt index 83caa65c..9d636faa 100644 --- a/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt +++ b/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt @@ -41,6 +41,11 @@ class SettingsFragment : ScreenFragment() { private var preferenceBinding: PreferenceItemBinding? = null private val preferences = mutableMapOf, 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, ) { diff --git a/src/main/kotlin/com/looker/droidify/service/DownloadService.kt b/src/main/kotlin/com/looker/droidify/service/DownloadService.kt index f045bd38..aaf4041b 100644 --- a/src/main/kotlin/com/looker/droidify/service/DownloadService.kt +++ b/src/main/kotlin/com/looker/droidify/service/DownloadService.kt @@ -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() { 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() 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() { } 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() { 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() { 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() { 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() { 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() { .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() { 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() { 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)) diff --git a/src/main/kotlin/com/looker/droidify/service/SyncService.kt b/src/main/kotlin/com/looker/droidify/service/SyncService.kt index 4715366a..153267d8 100644 --- a/src/main/kotlin/com/looker/droidify/service/SyncService.kt +++ b/src/main/kotlin/com/looker/droidify/service/SyncService.kt @@ -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() { 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() { 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() { 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() { 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() { 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() { 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() { val maxUpdates = 5 fun 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() { 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