diff --git a/src/main/kotlin/com/looker/droidify/Common.kt b/src/main/kotlin/com/looker/droidify/Common.kt index a0dc8668..2f00bfda 100644 --- a/src/main/kotlin/com/looker/droidify/Common.kt +++ b/src/main/kotlin/com/looker/droidify/Common.kt @@ -3,10 +3,12 @@ package com.looker.droidify 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 ROW_REPOSITORY_ID = "repository_id" const val ROW_PACKAGE_NAME = "package_name" diff --git a/src/main/kotlin/com/looker/droidify/MainActivity.kt b/src/main/kotlin/com/looker/droidify/MainActivity.kt index 87414d42..5774c5dc 100644 --- a/src/main/kotlin/com/looker/droidify/MainActivity.kt +++ b/src/main/kotlin/com/looker/droidify/MainActivity.kt @@ -2,6 +2,7 @@ package com.looker.droidify import android.content.Context import android.content.Intent +import android.content.pm.PackageInstaller import com.looker.droidify.ContextWrapperX.Companion.wrap import com.looker.droidify.screen.ScreenActivity @@ -19,7 +20,8 @@ class MainActivity : ScreenActivity() { ACTION_INSTALL -> handleSpecialIntent( SpecialIntent.Install( intent.packageName, - intent.getStringExtra(EXTRA_CACHE_FILE_NAME) + intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1), + intent.getParcelableExtra(Intent.EXTRA_INTENT) ) ) else -> super.handleIntent(intent) diff --git a/src/main/kotlin/com/looker/droidify/content/Preferences.kt b/src/main/kotlin/com/looker/droidify/content/Preferences.kt index e8a25a28..c5ec5dd8 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.InstallAfterSync, Key.IncompatibleVersions, Key.ListAnimation, Key.ProxyHost, @@ -132,6 +133,8 @@ object Preferences { "auto_sync", Value.EnumerationValue(Preferences.AutoSync.Wifi) ) + object InstallAfterSync : + Key("auto_sync_install", Value.BooleanValue(Android.sdk(31))) 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 00819462..880ffc50 100644 --- a/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt +++ b/src/main/kotlin/com/looker/droidify/installer/DefaultInstaller.kt @@ -4,15 +4,19 @@ 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 +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 { @@ -33,32 +37,85 @@ 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) private fun mDefaultInstaller(cacheFile: File) { - - val id = sessionInstaller.createSession(sessionParams) - - val session = sessionInstaller.openSession(id) - - session.use { activeSession -> - activeSession.openWrite("package", 0, cacheFile.length()).use { packageStream -> - cacheFile.inputStream().use { fileStream -> - fileStream.copyTo(packageStream) + // clean up inactive sessions + sessionInstaller.mySessions + .filter { session -> !session.isActive } + .forEach { session -> + try { + sessionInstaller.abandonSession(session.sessionId) + } + catch (_: SecurityException) { + Log.w( + "DefaultInstaller", + "Attempted to abandon a session we do not own." + ) } } - val pendingIntent = PendingIntent.getService(context, id, intent, flags) + // start new session + val id = sessionInstaller.createSession(sessionParams) + val session = sessionInstaller.openSession(id) - session.commit(pendingIntent.intentSender) + // get package name + val packageInfo = packageManager.getPackageArchiveInfo(cacheFile.absolutePath, 0) + val packageName = packageInfo?.packageName ?: "unknown-package" + + // error flags + var hasErrors = false + + session.use { activeSession -> + try { + activeSession.openWrite(packageName, 0, cacheFile.length()).use { packageStream -> + try { + cacheFile.inputStream().use { fileStream -> + fileStream.copyTo(packageStream) + } + } catch (_: FileNotFoundException) { + Log.w( + "DefaultInstaller", + "Cache file does not seem to exist." + ) + hasErrors = true + } catch (_: IOException) { + Log.w( + "DefaultInstaller", + "Failed to perform cache to package copy due to a bad pipe." + ) + hasErrors = true + } + } + } catch (_: SecurityException) { + Log.w( + "DefaultInstaller", + "Attempted to use a destroyed or sealed session when installing." + ) + hasErrors = true + } catch (_: IOException) { + Log.w( + "DefaultInstaller", + "Couldn't open up active session file for copying install data." + ) + hasErrors = true + } + } + + if (!hasErrors) { + session.commit(PendingIntent.getService(context, id, intent, flags).intentSender) + cacheFile.delete() } - 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 fec9dde0..a8154cdf 100644 --- a/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt +++ b/src/main/kotlin/com/looker/droidify/installer/InstallerService.kt @@ -1,33 +1,56 @@ 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.NOTIFICATION_CHANNEL_DOWNLOADING -import com.looker.droidify.NOTIFICATION_ID_DOWNLOADING +import com.looker.droidify.NOTIFICATION_CHANNEL_INSTALLER +import com.looker.droidify.NOTIFICATION_ID_INSTALLER +import com.looker.droidify.MainActivity import com.looker.droidify.R +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" + private const val INSTALLED_NOTIFICATION_TIMEOUT: Long = 5000 + 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 { 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, otherwise make notification + if (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 { @@ -46,32 +69,42 @@ 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 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 = try { - if (name != null) packageManager.getApplicationLabel( - packageManager.getApplicationInfo( - name, - PackageManager.GET_META_DATA - ) - ) else null - } catch (_: Exception) { - null - } + val appLabel = session?.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" + 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) @@ -79,23 +112,33 @@ 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_INSTALLER, 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, 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 -> { @@ -110,7 +153,7 @@ class InstallerService : Service() { .build() notificationManager.notify( notificationTag, - NOTIFICATION_ID_DOWNLOADING, + NOTIFICATION_ID_INSTALLER, notification ) } @@ -121,5 +164,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 + ) + } + } \ No newline at end of file diff --git a/src/main/kotlin/com/looker/droidify/screen/ScreenActivity.kt b/src/main/kotlin/com/looker/droidify/screen/ScreenActivity.kt index f1b927df..c43cf90f 100644 --- a/src/main/kotlin/com/looker/droidify/screen/ScreenActivity.kt +++ b/src/main/kotlin/com/looker/droidify/screen/ScreenActivity.kt @@ -1,6 +1,7 @@ package com.looker.droidify.screen import android.content.Intent +import android.content.pm.PackageInstaller import android.os.Bundle import android.os.Parcel import android.view.ViewGroup @@ -14,7 +15,7 @@ import com.looker.droidify.MainApplication import com.looker.droidify.R import com.looker.droidify.content.Preferences import com.looker.droidify.database.CursorOwner -import com.looker.droidify.installer.AppInstaller +import com.looker.droidify.installer.InstallerService import com.looker.droidify.ui.fragments.AppDetailFragment import com.looker.droidify.utility.KParcelable import com.looker.droidify.utility.extension.resources.getDrawableFromAttr @@ -31,7 +32,7 @@ abstract class ScreenActivity : AppCompatActivity() { sealed class SpecialIntent { object Updates : SpecialIntent() - class Install(val packageName: String?, val cacheFileName: String?) : SpecialIntent() + class Install(val packageName: String?, val status: Int?, val promptIntent: Intent?) : SpecialIntent() } private class FragmentStackItem( @@ -221,14 +222,24 @@ abstract class ScreenActivity : AppCompatActivity() { } is SpecialIntent.Install -> { val packageName = specialIntent.packageName - if (!packageName.isNullOrEmpty()) { + val status = specialIntent.status + val promptIntent = specialIntent.promptIntent + if (!packageName.isNullOrEmpty() && status != null && promptIntent != null) { lifecycleScope.launch { - specialIntent.cacheFileName?.let { - AppInstaller.getInstance(this@ScreenActivity) - ?.defaultInstaller?.install(packageName, it) - } + startService( + Intent(baseContext, InstallerService::class.java) + .putExtra(PackageInstaller.EXTRA_STATUS, status) + .putExtra( + PackageInstaller.EXTRA_PACKAGE_NAME, + packageName + ) + .putExtra(Intent.EXTRA_INTENT, promptIntent) + ) } } + else { + throw IllegalArgumentException("Missing parameters needed to relaunch InstallerService and trigger prompt.") + } Unit } }::class diff --git a/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt b/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt index 7adb4cd5..21e2bbea 100644 --- a/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt +++ b/src/main/kotlin/com/looker/droidify/screen/SettingsFragment.kt @@ -108,6 +108,10 @@ class SettingsFragment : ScreenFragment() { Preferences.AutoSync.Always -> getString(R.string.always) } } + addSwitch( + Preferences.Key.InstallAfterSync, getString(R.string.install_after_sync), + getString(R.string.install_after_sync_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/DownloadService.kt b/src/main/kotlin/com/looker/droidify/service/DownloadService.kt index 256a9e9c..6f01ca70 100644 --- a/src/main/kotlin/com/looker/droidify/service/DownloadService.kt +++ b/src/main/kotlin/com/looker/droidify/service/DownloadService.kt @@ -3,8 +3,6 @@ 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 @@ -16,7 +14,6 @@ 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.* @@ -30,11 +27,7 @@ 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() @@ -43,34 +36,6 @@ class DownloadService : ConnectionService() { private val scope = CoroutineScope(Dispatchers.Default) private val mainDispatcher = Dispatchers.Main - 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) - ) - } - } - } - } - sealed class State(val packageName: String, val name: String) { class Pending(packageName: String, name: String) : State(packageName, name) class Connecting(packageName: String, name: String) : State(packageName, name) @@ -220,11 +185,13 @@ class DownloadService : ConnectionService() { .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 @@ -275,33 +242,6 @@ class DownloadService : ConnectionService() { .build()) } - private fun showNotificationInstall(task: Task) { - notificationManager.notify( - task.notificationTag, NOTIFICATION_ID_DOWNLOADING, NotificationCompat - .Builder(this, 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(mainDispatcher) { @@ -309,12 +249,10 @@ class DownloadService : ConnectionService() { 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) + } } } diff --git a/src/main/kotlin/com/looker/droidify/service/SyncService.kt b/src/main/kotlin/com/looker/droidify/service/SyncService.kt index 227b76b4..bd1a9702 100644 --- a/src/main/kotlin/com/looker/droidify/service/SyncService.kt +++ b/src/main/kotlin/com/looker/droidify/service/SyncService.kt @@ -19,6 +19,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 @@ -70,6 +71,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() { @@ -172,11 +175,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 } } @@ -372,33 +377,38 @@ class SyncService : ConnectionService() { handleNextTask(hasUpdates) } } else if (started != Started.NO) { - if (hasUpdates && Preferences[Preferences.Key.UpdateNotify]) { - val disposable = RxUtils - .querySingle { it -> - db.productDao - .query( - installed = true, - updates = true, - searchQuery = "", - section = ProductItem.Section.All, - order = ProductItem.Order.NAME, - signal = it - ) - .use { - it.asSequence().map { it.getProductItem() }.toList() - } - } - .subscribeOn(Schedulers.io()) - .observeOn(AndroidSchedulers.mainThread()) - .subscribe { result, throwable -> - throwable?.printStackTrace() - currentTask = null - handleNextTask(false) - val blocked = updateNotificationBlockerFragment?.get()?.isAdded == true - if (!blocked && result != null && result.isNotEmpty()) { - displayUpdatesNotification(result) + val disposable = RxUtils + .querySingle { it -> + db.productDao + .query( + installed = true, + updates = true, + searchQuery = "", + section = ProductItem.Section.All, + order = ProductItem.Order.NAME, + signal = it + ) + .use { + it.asSequence().map { it.getProductItem() } + .toList() } + } + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe { result, throwable -> + throwable?.printStackTrace() + currentTask = null + handleNextTask(false) + if (result.isNotEmpty()) { + if (Preferences[Preferences.Key.InstallAfterSync]) + runAutoUpdate(result) + if (hasUpdates && Preferences[Preferences.Key.UpdateNotify] && + updateNotificationBlockerFragment?.get()?.isAdded == true + ) + displayUpdatesNotification(result) } + } + if (hasUpdates) { currentTask = CurrentTask(null, disposable, true, State.Finishing) } else { scope.launch { mutableFinishState.emit(Unit) } @@ -413,6 +423,54 @@ class SyncService : ConnectionService() { } } + /** + * 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.InstallAfterSync]) { + // run startUpdate on every item + productItems.map { productItem -> + Pair( + Database.InstalledAdapter.get(productItem.packageName, null), + Database.RepositoryAdapter.get(productItem.repositoryId) + ) + } + .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 + ) + .filter { product -> product.repositoryId == repository.id } + .map { product -> Pair(product, repository) } + + scope.launch { + Utils.startUpdate( + installedItem.packageName, + installedRepository.first, + productRepository, + downloadConnection + ) + } + } + } + } + } + + /** + * Displays summary of available updates. + * + * @param productItems list of apps pending updates + */ private fun displayUpdatesNotification(productItems: List) { val maxUpdates = 5 fun T.applyHack(callback: T.() -> Unit): T = apply(callback) diff --git a/src/main/kotlin/com/looker/droidify/utility/Utils.kt b/src/main/kotlin/com/looker/droidify/utility/Utils.kt index e1180598..8ed0f609 100644 --- a/src/main/kotlin/com/looker/droidify/utility/Utils.kt +++ b/src/main/kotlin/com/looker/droidify/utility/Utils.kt @@ -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 @@ -172,6 +175,17 @@ 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)) + } + } fun Cursor.getProduct(): Product = getBlob(getColumnIndex(ROW_DATA)) diff --git a/src/main/res/values/strings.xml b/src/main/res/values/strings.xml index 12827b55..6469fdc2 100644 --- a/src/main/res/values/strings.xml +++ b/src/main/res/values/strings.xml @@ -181,4 +181,6 @@ Installed applications Sort & Filter New applications + Install updates automatically + Automatically install app updates after syncing repositories