diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index dd419fe399..58fea94f0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -200,6 +200,14 @@ android:name=".data.notification.NotificationReceiver" android:exported="false" /> + + + + + + diff --git a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt index ce0e136ac1..3bd969dae2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/Migrations.kt @@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.updater.UpdaterJob +import eu.kanade.tachiyomi.data.updater.UpdaterService import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE import eu.kanade.tachiyomi.ui.library.LibraryPresenter @@ -29,6 +30,10 @@ object Migrations { */ fun upgrade(preferences: PreferencesHelper): Boolean { val context = preferences.context + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit { + remove(UpdaterService.NOTIFY_ON_INSTALL_KEY) + } val oldVersion = preferences.lastVersionCode().getOrDefault() if (oldVersion < BuildConfig.VERSION_CODE) { preferences.lastVersionCode().set(BuildConfig.VERSION_CODE) @@ -103,7 +108,6 @@ object Migrations { } if (oldVersion < 71) { // Migrate DNS over HTTPS setting - val prefs = PreferenceManager.getDefaultSharedPreferences(context) val wasDohEnabled = prefs.getBoolean("enable_doh", false) if (wasDohEnabled) { prefs.edit { @@ -114,7 +118,6 @@ object Migrations { } if (oldVersion < 73) { // Reset rotation to Free after replacing Lock - val prefs = PreferenceManager.getDefaultSharedPreferences(context) if (prefs.contains("pref_rotation_type_key")) { prefs.edit { putInt("pref_rotation_type_key", 1) @@ -128,7 +131,6 @@ object Migrations { } } if (oldVersion < 75) { - val prefs = PreferenceManager.getDefaultSharedPreferences(context) val wasShortcutsDisabled = !prefs.getBoolean("show_manga_app_shortcuts", true) if (wasShortcutsDisabled) { prefs.edit { @@ -149,7 +151,6 @@ object Migrations { } if (oldVersion < 77) { // Migrate Rotation and Viewer values to default values for viewer_flags - val prefs = PreferenceManager.getDefaultSharedPreferences(context) val newOrientation = when (prefs.getInt("pref_rotation_type_key", 1)) { 1 -> OrientationType.FREE.flagValue 2 -> OrientationType.PORTRAIT.flagValue diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt index cb465aef7b..c04e3d47d6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/Notifications.kt @@ -20,6 +20,8 @@ object Notifications { const val ID_UPDATER = 1 const val ID_DOWNLOAD_IMAGE = 2 const val ID_INSTALL = 3 + const val CHANNEL_UPDATED = "updated_channel" + const val ID_INSTALLED = -6 /** * Notification channel and ids used by the library updater. @@ -139,15 +141,24 @@ object Notifications { ) context.notificationManager.createNotificationChannels(channels) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - val channel = NotificationChannel( - CHANNEL_EXT_PROGRESS, - context.getString(R.string.updating_extensions), - NotificationManager.IMPORTANCE_LOW - ).apply { - setShowBadge(false) - setSound(null, null) - } - context.notificationManager.createNotificationChannel(channel) + val newChannels = listOf( + NotificationChannel( + CHANNEL_EXT_PROGRESS, + context.getString(R.string.updating_extensions), + NotificationManager.IMPORTANCE_LOW + ).apply { + setShowBadge(false) + setSound(null, null) + }, + NotificationChannel( + CHANNEL_UPDATED, + context.getString(R.string.update_completed), + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + setShowBadge(false) + } + ) + context.notificationManager.createNotificationChannels(newChannels) } } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterBroadcast.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterBroadcast.kt new file mode 100644 index 0000000000..59644902d8 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterBroadcast.kt @@ -0,0 +1,55 @@ +package eu.kanade.tachiyomi.data.updater + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import androidx.core.content.edit +import androidx.core.net.toUri +import androidx.preference.PreferenceManager +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.util.system.toast + +class UpdaterBroadcast : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (UpdaterService.PACKAGE_INSTALLED_ACTION == intent.action) { + val extras = intent.extras ?: return + when (val status = extras.getInt(PackageInstaller.EXTRA_STATUS)) { + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + val confirmIntent = extras[Intent.EXTRA_INTENT] as? Intent + context.startActivity(confirmIntent) + } + PackageInstaller.STATUS_SUCCESS -> { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + prefs.edit { + remove(UpdaterService.NOTIFY_ON_INSTALL_KEY) + } + val notifyOnInstall = extras.getBoolean(UpdaterService.EXTRA_NOTIFY_ON_INSTALL, false) + try { + if (notifyOnInstall) { + UpdaterNotifier(context).onInstallFinished() + } + } finally { + UpdaterService.stop(context) + } + } + PackageInstaller.STATUS_FAILURE, PackageInstaller.STATUS_FAILURE_ABORTED, PackageInstaller.STATUS_FAILURE_BLOCKED, PackageInstaller.STATUS_FAILURE_CONFLICT, PackageInstaller.STATUS_FAILURE_INCOMPATIBLE, PackageInstaller.STATUS_FAILURE_INVALID, PackageInstaller.STATUS_FAILURE_STORAGE -> { + if (status != PackageInstaller.STATUS_FAILURE_ABORTED) { + context.toast(R.string.could_not_install_update) + val uri = intent.getStringExtra(UpdaterService.EXTRA_FILE_URI) ?: return + UpdaterNotifier(context).onInstallError(uri.toUri()) + } + } + } + } else if (intent.action == Intent.ACTION_MY_PACKAGE_REPLACED) { + val prefs = PreferenceManager.getDefaultSharedPreferences(context) + val notifyOnInstall = prefs.getBoolean(UpdaterService.NOTIFY_ON_INSTALL_KEY, false) + prefs.edit { + remove(UpdaterService.NOTIFY_ON_INSTALL_KEY) + } + if (notifyOnInstall) { + UpdaterNotifier(context).onInstallFinished() + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt index f72237eb0d..eee2d771b1 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterNotifier.kt @@ -7,6 +7,7 @@ import android.net.Uri import androidx.core.app.NotificationCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri +import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationReceiver @@ -44,6 +45,7 @@ internal class UpdaterNotifier(private val context: Context) { fun promptUpdate(body: String, url: String, releaseUrl: String) { val intent = Intent(context, UpdaterService::class.java).apply { putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url) + putExtra(UpdaterService.EXTRA_NOTIFY_ON_INSTALL, true) } val pendingIntent = NotificationReceiver.openUpdatePendingActivity(context, body, url) @@ -155,6 +157,31 @@ internal class UpdaterNotifier(private val context: Context) { notificationBuilder.show(Notifications.ID_INSTALL) } + /** + * Call when apk download is finished. + * + * @param uri path location of apk. + */ + fun onInstallFinished() { + with(NotificationCompat.Builder(context, Notifications.CHANNEL_UPDATED)) { + setContentTitle(context.getString(R.string.updated_to_, BuildConfig.VERSION_NAME)) + setSmallIcon(R.drawable.ic_tachij2k) + setAutoCancel(true) + setOngoing(false) + setProgress(0, 0, false) + val pendingIntent = PendingIntent.getActivity( + context, + 0, + context.packageManager.getLaunchIntentForPackage(BuildConfig.APPLICATION_ID), + PendingIntent.FLAG_UPDATE_CURRENT + ) + setContentIntent(pendingIntent) + clearActions() + addReleasePageAction() + show(Notifications.ID_INSTALLED) + } + } + /** * Call when apk download throws a error * @@ -186,6 +213,32 @@ internal class UpdaterNotifier(private val context: Context) { notificationBuilder.show(Notifications.ID_UPDATER) } + fun onInstallError(uri: Uri) { + with(notificationBuilder) { + setContentText(context.getString(R.string.could_not_install_update)) + setSmallIcon(android.R.drawable.stat_sys_warning) + setOnlyAlertOnce(false) + setAutoCancel(false) + setProgress(0, 0, false) + color = ContextCompat.getColor(context, R.color.colorAccent) + clearActions() + // Retry action + addAction( + R.drawable.ic_refresh_24dp, + context.getString(R.string.retry), + NotificationHandler.installApkPendingActivity(context, uri) + ) + // Cancel action + addAction( + R.drawable.ic_close_24dp, + context.getString(R.string.cancel), + NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER) + ) + addReleasePageAction() + } + notificationBuilder.show(Notifications.ID_UPDATER) + } + fun cancel() { NotificationReceiver.dismissNotification(context, Notifications.ID_UPDATER) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt index ef574a782e..cd1e17f397 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterService.kt @@ -4,9 +4,11 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.PackageInstaller import android.os.Build import android.os.IBinder import android.os.PowerManager +import androidx.core.content.edit import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.Notifications @@ -18,12 +20,11 @@ import eu.kanade.tachiyomi.network.newCallWithProgress import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.system.acquireWakeLock -import eu.kanade.tachiyomi.util.system.isServiceRunning +import eu.kanade.tachiyomi.util.system.toast import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job -import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import okhttp3.Call import okhttp3.internal.http2.ErrorCode @@ -64,6 +65,8 @@ class UpdaterService : Service() { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { if (intent == null) return START_NOT_STICKY + instance = this + val handler = CoroutineExceptionHandler { _, exception -> Timber.e(exception) stopSelf(startId) @@ -71,9 +74,10 @@ class UpdaterService : Service() { val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return START_NOT_STICKY val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name) + val notifyOnInstall = intent.getBooleanExtra(EXTRA_NOTIFY_ON_INSTALL, false) runningJob = GlobalScope.launch(handler) { - downloadApk(title, url) + downloadApk(title, url, notifyOnInstall) } runningJob?.invokeOnCompletion { stopSelf(startId) } @@ -88,6 +92,9 @@ class UpdaterService : Service() { override fun onDestroy() { destroyJob() + if (instance == this) { + instance = null + } super.onDestroy() } @@ -104,7 +111,7 @@ class UpdaterService : Service() { * * @param url url location of file */ - private suspend fun downloadApk(title: String, url: String) { + private suspend fun downloadApk(title: String, url: String, notifyOnInstall: Boolean) { // Show notification download starting. notifier.onDownloadStarted(title) @@ -141,7 +148,11 @@ class UpdaterService : Service() { response.close() throw Exception("Unsuccessful response") } - notifier.onDownloadFinished(apkFile.getUriCompat(this)) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + startInstalling(apkFile, notifyOnInstall) + } else { + notifier.onDownloadFinished(apkFile.getUriCompat(this)) + } } catch (error: Exception) { Timber.e(error) if (error is CancellationException || @@ -154,30 +165,77 @@ class UpdaterService : Service() { } } + private fun startInstalling(file: File, notifyOnInstall: Boolean) { + try { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return + val packageInstaller = packageManager.packageInstaller + val data = file.inputStream() + + val params = PackageInstaller.SessionParams( + PackageInstaller.SessionParams.MODE_FULL_INSTALL + ) + params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) + val sessionId = packageInstaller.createSession(params) + val session = packageInstaller.openSession(sessionId) + session.openWrite("package", 0, -1).use { packageInSession -> + data.copyTo(packageInSession) + } + if (notifyOnInstall) { + val prefs = PreferenceManager.getDefaultSharedPreferences(this) + prefs.edit { + putBoolean(NOTIFY_ON_INSTALL_KEY, true) + } + } + + val newIntent = Intent(this, UpdaterBroadcast::class.java) + .setAction(PACKAGE_INSTALLED_ACTION) + .putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall) + .putExtra(EXTRA_FILE_URI, file.getUriCompat(this).toString()) + + val pendingIntent = PendingIntent.getBroadcast(this, -10053, newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE) + val statusReceiver = pendingIntent.intentSender + session.commit(statusReceiver) + data.close() + } catch (error: Exception) { + // Either install package can't be found (probably bots) or there's a security exception + // with the download manager. Nothing we can workaround. + toast(error.message) + } + } + companion object { + const val PACKAGE_INSTALLED_ACTION = + "${BuildConfig.APPLICATION_ID}.SESSION_SELF_API_PACKAGE_INSTALLED" + internal const val EXTRA_NOTIFY_ON_INSTALL = "${BuildConfig.APPLICATION_ID}.UpdaterService.ACTION_ON_INSTALL" internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_URL" + internal const val EXTRA_FILE_URI = "${BuildConfig.APPLICATION_ID}.UpdaterService.FILE_URI" internal const val EXTRA_DOWNLOAD_TITLE = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_TITLE" + internal const val NOTIFY_ON_INSTALL_KEY = "notify_on_install_complete" + + private var instance: UpdaterService? = null + /** * Returns the status of the service. * * @param context the application context. * @return true if the service is running, false otherwise. */ - private fun isRunning(context: Context): Boolean = - context.isServiceRunning(UpdaterService::class.java) + fun isRunning(): Boolean = instance != null /** * Downloads a new update and let the user install the new version from a notification. * @param context the application context. * @param url the url to the new update. */ - fun start(context: Context, url: String, title: String = context.getString(R.string.app_name)) { - if (!isRunning(context)) { + fun start(context: Context, url: String, notifyOnInstall: Boolean) { + if (!isRunning()) { + val title = context.getString(R.string.app_name) val intent = Intent(context, UpdaterService::class.java).apply { putExtra(EXTRA_DOWNLOAD_TITLE, title) putExtra(EXTRA_DOWNLOAD_URL, url) + putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall) } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { context.startService(intent) @@ -202,9 +260,10 @@ class UpdaterService : Service() { * @param url the url to the new update. * @return [PendingIntent] */ - internal fun downloadApkPendingService(context: Context, url: String): PendingIntent { + internal fun downloadApkPendingService(context: Context, url: String, notifyOnInstall: Boolean = false): PendingIntent { val intent = Intent(context, UpdaterService::class.java).apply { putExtra(EXTRA_DOWNLOAD_URL, url) + putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall) } return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt index 1938da45fe..9c421daf97 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/AboutController.kt @@ -198,7 +198,7 @@ class AboutController : SettingsController() { if (appContext != null) { // Start download val url = args.getString(URL_KEY) ?: "" - UpdaterService.start(appContext, url) + UpdaterService.start(appContext, url, true) } } .negativeButton(R.string.ignore) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2b2a388a5b..1e7ce05b7f 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,6 +117,8 @@ No new updates available Searching for updates… Release page + Could not install update + Update completed