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.
This commit is contained in:
Matthew Crossman 2022-01-03 20:21:36 +11:00
parent adf305ffc0
commit fce311098d
No known key found for this signature in database
GPG Key ID: C6B942B019794CC2
5 changed files with 129 additions and 60 deletions

View File

@ -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<Boolean>("auto_sync_install", Value.BooleanValue(true))
object IncompatibleVersions :
Key<Boolean>("incompatible_versions", Value.BooleanValue(false))

View File

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

View File

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

View File

@ -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<SyncService.Binder>() {
private var updateNotificationBlockerFragment: WeakReference<Fragment>? = 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<SyncService.Binder>() {
.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<SyncService.Binder>() {
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<SyncService.Binder>() {
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<SyncService.Binder>() {
}
}
private fun displayUpdatesNotification(productItems: List<ProductItem>) {
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)
.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<ProductItem>) {
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<ProductItem>) {
if (updateNotificationBlockerFragment?.get()?.isAdded == true && Preferences[Preferences.Key.UpdateNotify]) {
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)
.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() {

View File

@ -180,4 +180,6 @@
<string name="installed_applications">Installed applications</string>
<string name="sort_filter">Sort &amp; Filter</string>
<string name="new_applications">New applications</string>
<string name="sync_auto_install">Update apps after sync</string>
<string name="sync_auto_install_summary">Automatically install app updates after syncing repositories</string>
</resources>