From fce311098dce1b341563c877b4bcb00c7ad75039 Mon Sep 17 00:00:00 2001 From: Matthew Crossman Date: Mon, 3 Jan 2022 20:21:36 +1100 Subject: [PATCH] Implement automatic updates after repository sync Initial implementation of fully automatic updates, allowing apps to be updated after a repo sync has been completed. It can be enabled with a new preference in settings. Implemented by greatly modifying SyncService to allow updates to be run on every completed sync before notifications would be outputted. Note that the update notification no longer appears if auto updates are enabled. BuildConfig.DEBUG is used to force syncing to run to completion, even if there are no changes to the repos. This allow reliable testing by turning the sync button into an "update all" button. This should be removed once an "Update all" button has been implemented in the updates tab. Cache file checking in DefaultInstaller is now more robust. --- .../looker/droidify/content/Preferences.kt | 3 + .../droidify/installer/DefaultInstaller.kt | 18 +- .../droidify/screen/SettingsFragment.kt | 4 + .../looker/droidify/service/SyncService.kt | 162 ++++++++++++------ src/main/res/values/strings.xml | 2 + 5 files changed, 129 insertions(+), 60 deletions(-) diff --git a/src/main/kotlin/com/looker/droidify/content/Preferences.kt b/src/main/kotlin/com/looker/droidify/content/Preferences.kt index 664635bf..afcd9a27 100644 --- a/src/main/kotlin/com/looker/droidify/content/Preferences.kt +++ b/src/main/kotlin/com/looker/droidify/content/Preferences.kt @@ -24,6 +24,7 @@ object Preferences { private val keys = sequenceOf( Key.Language, Key.AutoSync, + Key.AutoSyncInstall, Key.IncompatibleVersions, Key.ListAnimation, Key.ProxyHost, @@ -130,6 +131,8 @@ object Preferences { "auto_sync", Value.EnumerationValue(Preferences.AutoSync.Wifi) ) + object AutoSyncInstall : + Key("auto_sync_install", Value.BooleanValue(true)) object IncompatibleVersions : Key("incompatible_versions", Value.BooleanValue(false)) diff --git a/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt b/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt index 503421cf..a600ab09 100644 --- a/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt +++ b/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt @@ -4,11 +4,13 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller.SessionParams +import android.util.Log import com.looker.droidify.content.Cache import com.looker.droidify.utility.extension.android.Android import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import java.io.File +import java.io.FileNotFoundException class DefaultInstaller(context: Context) : BaseInstaller(context) { @@ -51,18 +53,20 @@ class DefaultInstaller(context: Context) : BaseInstaller(context) { val session = sessionInstaller.openSession(id) - if (cacheFile.exists()) { - session.use { activeSession -> - activeSession.openWrite("package", 0, cacheFile.length()).use { packageStream -> + session.use { activeSession -> + activeSession.openWrite("package", 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.") } - - val pendingIntent = PendingIntent.getService(context, id, intent, flags) - - session.commit(pendingIntent.intentSender) } + + val pendingIntent = PendingIntent.getService(context, id, intent, flags) + + session.commit(pendingIntent.intentSender) } cacheFile.delete() } diff --git a/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt b/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt index d6fa1e5a..83caa65c 100644 --- a/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt +++ b/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt @@ -103,6 +103,10 @@ class SettingsFragment : ScreenFragment() { Preferences.AutoSync.Always -> getString(R.string.always) } } + addSwitch( + Preferences.Key.AutoSyncInstall, getString(R.string.sync_auto_install), + getString(R.string.sync_auto_install_summary) + ) addSwitch( Preferences.Key.UpdateNotify, getString(R.string.notify_about_updates), getString(R.string.notify_about_updates_summary) diff --git a/src/main/kotlin/com/looker/droidify/service/SyncService.kt b/src/main/kotlin/com/looker/droidify/service/SyncService.kt index 3982b6fb..4715366a 100644 --- a/src/main/kotlin/com/looker/droidify/service/SyncService.kt +++ b/src/main/kotlin/com/looker/droidify/service/SyncService.kt @@ -23,6 +23,7 @@ import com.looker.droidify.entity.ProductItem import com.looker.droidify.entity.Repository import com.looker.droidify.index.RepositoryUpdater import com.looker.droidify.utility.RxUtils +import com.looker.droidify.utility.Utils import com.looker.droidify.utility.extension.android.Android import com.looker.droidify.utility.extension.android.asSequence import com.looker.droidify.utility.extension.android.notificationManager @@ -73,6 +74,8 @@ class SyncService : ConnectionService() { private var updateNotificationBlockerFragment: WeakReference? = null + private val downloadConnection = Connection(DownloadService::class.java) + enum class SyncRequest { AUTO, MANUAL, FORCE } inner class Binder : android.os.Binder() { @@ -173,11 +176,13 @@ class SyncService : ConnectionService() { .let(notificationManager::createNotificationChannel) } + downloadConnection.bind(this) stateSubject.onEach { publishForegroundState(false, it) }.launchIn(scope) } override fun onDestroy() { super.onDestroy() + downloadConnection.unbind(this) cancelTasks { true } cancelCurrentTask { true } } @@ -366,14 +371,14 @@ class SyncService : ConnectionService() { if (throwable != null && task.manual) { showNotificationError(repository, throwable as Exception) } - handleNextTask(result == true || hasUpdates) + handleNextTask(BuildConfig.DEBUG || result == true || hasUpdates) } currentTask = CurrentTask(task, disposable, hasUpdates, initialState) } else { handleNextTask(hasUpdates) } } else if (started != Started.NO) { - if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) { + if (hasUpdates) { val disposable = RxUtils .querySingle { it -> Database.ProductAdapter @@ -396,9 +401,8 @@ class SyncService : ConnectionService() { throwable?.printStackTrace() currentTask = null handleNextTask(false) - val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true - if (!blocked && result != null && result.isNotEmpty()) { - displayUpdatesNotification(result) + if (result.isNotEmpty()) { + runAutoUpdate(result) } } currentTask = CurrentTask(null, disposable, true, State.Finishing) @@ -415,58 +419,110 @@ class SyncService : ConnectionService() { } } - private fun displayUpdatesNotification(productItems: List) { - 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) - .setSmallIcon(R.drawable.ic_new_releases) - .setContentTitle(getString(R.string.new_updates_available)) - .setContentText( - resources.getQuantityString( - R.plurals.new_updates_DESC_FORMAT, - productItems.size, productItems.size - ) + /** + * Performs automatic update after a repo sync if it is enabled. Otherwise, it continues on to + * displayUpdatesNotification. + * + * @param productItems a list of apps pending updates + * @see SyncService.displayUpdatesNotification + */ + private fun runAutoUpdate(productItems: List) { + if (Preferences[Preferences.Key.AutoSyncInstall]) { + // run startUpdate on every item + productItems.map { productItem -> + Pair( + Database.InstalledAdapter.get(productItem.packageName, null), + Database.RepositoryAdapter.get(productItem.repositoryId) ) - .setColor( - ContextThemeWrapper(this, R.style.Theme_Main_Light) - .getColorFromAttr(android.R.attr.colorPrimary).defaultColor - ) - .setContentIntent( - PendingIntent.getActivity( - this, - 0, - Intent(this, MainActivity::class.java) - .setAction(MainActivity.ACTION_UPDATES), - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - else - PendingIntent.FLAG_UPDATE_CURRENT - ) - ) - .setStyle(NotificationCompat.InboxStyle().applyHack { - for (productItem in productItems.take(maxUpdates)) { - val builder = SpannableStringBuilder(productItem.name) - builder.setSpan( - ForegroundColorSpan(Color.BLACK), 0, builder.length, - SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + } + .filter { pair -> pair.first != null && pair.second != null } + .forEach { installedRepository -> + run { + // Redundant !! as linter doesn't recognise the above filter's effects + val installedItem = installedRepository.first!! + val repository = installedRepository.second!! + + val productRepository = Database.ProductAdapter.get( + installedItem.packageName, + null ) - builder.append(' ').append(productItem.version) - addLine(builder) - } - if (productItems.size > maxUpdates) { - val summary = - getString(R.string.plus_more_FORMAT, productItems.size - maxUpdates) - if (Android.sdk(24)) { - addLine(summary) - } else { - setSummaryText(summary) + .filter { product -> product.repositoryId == repository.id } + .map { product -> Pair(product, repository) } + + scope.launch { + Utils.startUpdate( + installedItem.packageName, + installedRepository.first, + productRepository, + downloadConnection + ) } } - }) - .build() - ) + } + } else { + displayUpdatesNotification(productItems) + } + } + + /** + * Displays summary of available updates. + * + * @param productItems list of apps pending updates + */ + private fun displayUpdatesNotification(productItems: List) { + if (updateNotificationBlockerFragment?.get()?.isAdded == true && Preferences[Preferences.Key.UpdateNotify]) { + 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) + .setSmallIcon(R.drawable.ic_new_releases) + .setContentTitle(getString(R.string.new_updates_available)) + .setContentText( + resources.getQuantityString( + R.plurals.new_updates_DESC_FORMAT, + productItems.size, productItems.size + ) + ) + .setColor( + ContextThemeWrapper(this, R.style.Theme_Main_Light) + .getColorFromAttr(android.R.attr.colorPrimary).defaultColor + ) + .setContentIntent( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java) + .setAction(MainActivity.ACTION_UPDATES), + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + else + PendingIntent.FLAG_UPDATE_CURRENT + ) + ) + .setStyle(NotificationCompat.InboxStyle().applyHack { + for (productItem in productItems.take(maxUpdates)) { + val builder = SpannableStringBuilder(productItem.name) + builder.setSpan( + ForegroundColorSpan(Color.BLACK), 0, builder.length, + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE + ) + builder.append(' ').append(productItem.version) + addLine(builder) + } + if (productItems.size > maxUpdates) { + val summary = + getString(R.string.plus_more_FORMAT, productItems.size - maxUpdates) + if (Android.sdk(24)) { + addLine(summary) + } else { + setSummaryText(summary) + } + } + }) + .build() + ) + } } class Job : JobService() { diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 83f48423..03ff6e52 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -180,4 +180,6 @@ Installed applications Sort & Filter New applications + Update apps after sync + Automatically install app updates after syncing repositories