diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index d9e377c7f4..58fea94f0b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -200,10 +200,22 @@ 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/NotificationReceiver.kt b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt index a1cb5ff53c..c9ccbd1c30 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/notification/NotificationReceiver.kt @@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.data.download.DownloadService import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.updater.UpdaterService +import eu.kanade.tachiyomi.extension.ExtensionInstallService import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.manga.MangaDetailsController @@ -71,6 +72,7 @@ class NotificationReceiver : BroadcastReceiver() { ) // Cancel library update and dismiss notification ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) + ACTION_CANCEL_EXTENSION_UPDATE -> cancelExtensionUpdate(context) ACTION_CANCEL_UPDATE_DOWNLOAD -> cancelDownloadUpdate(context) ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context) // Share backup file @@ -199,6 +201,17 @@ class NotificationReceiver : BroadcastReceiver() { Handler().post { dismissNotification(context, Notifications.ID_LIBRARY_PROGRESS) } } + /** + * Method called when user wants to stop a library update + * + * @param context context of application + * @param notificationId id of notification + */ + private fun cancelExtensionUpdate(context: Context) { + ExtensionInstallService.stop(context) + Handler().post { dismissNotification(context, Notifications.ID_EXTENSION_PROGRESS) } + } + /** * Method called when user wants to mark as read * @@ -251,6 +264,9 @@ class NotificationReceiver : BroadcastReceiver() { // Called to cancel library update. private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.CANCEL_LIBRARY_UPDATE" + // Called to cancel extension update. + private const val ACTION_CANCEL_EXTENSION_UPDATE = "$ID.$NAME.CANCEL_EXTENSION_UPDATE" + private const val ACTION_CANCEL_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_UPDATE_DOWNLOAD" // Called to mark as read @@ -568,6 +584,19 @@ class NotificationReceiver : BroadcastReceiver() { return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) } + /** + * Returns [PendingIntent] that starts a service which stops the library update + * + * @param context context of application + * @return [PendingIntent] + */ + internal fun cancelExtensionUpdatePendingBroadcast(context: Context): PendingIntent { + val intent = Intent(context, NotificationReceiver::class.java).apply { + action = ACTION_CANCEL_EXTENSION_UPDATE + } + return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + } + /** * Returns [PendingIntent] that cancels the download for a Tachiyomi update * 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 2b0fe72714..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. @@ -48,6 +50,9 @@ object Notifications { const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel" const val ID_UPDATES_TO_EXTS = -401 + const val CHANNEL_EXT_PROGRESS = "ext_update_progress_channel" + const val ID_EXTENSION_PROGRESS = -402 + private const val GROUP_BACKUP_RESTORE = "group_backup_restore" const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel" const val ID_RESTORE_PROGRESS = -501 @@ -135,5 +140,25 @@ object Notifications { ) ) context.notificationManager.createNotificationChannels(channels) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + 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/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 78dba72b07..a8320b8fd7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -229,6 +229,8 @@ object PreferenceKeys { const val incognitoMode = "incognito_mode" + const val shouldAutoUpdate = "should_auto_update" + const val defaultChapterFilterByRead = "default_chapter_filter_by_read" const val defaultChapterFilterByDownloaded = "default_chapter_filter_by_downloaded" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 41cf0a9e17..1824968f05 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.preference import android.content.Context import android.content.SharedPreferences import android.net.Uri +import android.os.Build import android.os.Environment import androidx.appcompat.app.AppCompatDelegate import androidx.core.content.edit @@ -13,6 +14,7 @@ import com.tfcporciuncula.flow.FlowSharedPreferences import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.track.TrackService +import eu.kanade.tachiyomi.data.updater.AutoUpdaterJob import eu.kanade.tachiyomi.ui.library.filter.FilterBottomSheet import eu.kanade.tachiyomi.ui.reader.settings.OrientationType import eu.kanade.tachiyomi.ui.reader.settings.PageLayout @@ -120,8 +122,9 @@ class PreferencesHelper(val context: Context) { fun themeDarkAmoled() = flowPrefs.getBoolean(Keys.themeDarkAmoled, false) - fun lightTheme() = flowPrefs.getEnum(Keys.lightTheme, Themes.DEFAULT) - fun darkTheme() = flowPrefs.getEnum(Keys.darkTheme, Themes.DEFAULT) + val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + fun lightTheme() = flowPrefs.getEnum(Keys.lightTheme, if (isOnA12) Themes.MONET else Themes.DEFAULT) + fun darkTheme() = flowPrefs.getEnum(Keys.darkTheme, if (isOnA12) Themes.MONET else Themes.DEFAULT) fun pageTransitions() = flowPrefs.getBoolean(Keys.enableTransitions, true) @@ -424,6 +427,10 @@ class PreferencesHelper(val context: Context) { fun incognitoMode() = flowPrefs.getBoolean(Keys.incognitoMode, false) + fun hasPromptedBeforeUpdateAll() = flowPrefs.getBoolean("has_prompted_update_all", false) + + fun shouldAutoUpdate() = prefs.getInt(Keys.shouldAutoUpdate, AutoUpdaterJob.ONLY_ON_UNMETERED) + fun filterChapterByRead() = flowPrefs.getInt(Keys.defaultChapterFilterByRead, Manga.SHOW_ALL) fun filterChapterByDownloaded() = flowPrefs.getInt(Keys.defaultChapterFilterByDownloaded, Manga.SHOW_ALL) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/updater/AutoUpdaterJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AutoUpdaterJob.kt new file mode 100644 index 0000000000..8accacc6bb --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/AutoUpdaterJob.kt @@ -0,0 +1,83 @@ +package eu.kanade.tachiyomi.data.updater + +import android.app.ActivityManager +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND +import android.app.ActivityManager.RunningAppProcessInfo.IMPORTANCE_VISIBLE +import android.content.Context +import androidx.core.app.NotificationCompat +import androidx.work.Constraints +import androidx.work.CoroutineWorker +import androidx.work.ExistingWorkPolicy +import androidx.work.NetworkType +import androidx.work.OneTimeWorkRequestBuilder +import androidx.work.WorkManager +import androidx.work.WorkerParameters +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.util.system.notificationManager +import kotlinx.coroutines.coroutineScope +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +class AutoUpdaterJob(private val context: Context, workerParams: WorkerParameters) : + CoroutineWorker(context, workerParams) { + + override suspend fun doWork(): Result = coroutineScope { + try { + val result = UpdateChecker.getUpdateChecker().checkForUpdate() + if (result is UpdateResult.NewUpdate<*> && !UpdaterService.isRunning()) { + UpdaterNotifier(context).cancel() + UpdaterNotifier.releasePageUrl = result.release.releaseLink + UpdaterService.start(context, result.release.downloadLink, false) + } + Result.success() + } catch (e: Exception) { + Result.failure() + } + } + + fun foregrounded(): Boolean { + val appProcessInfo = ActivityManager.RunningAppProcessInfo() + ActivityManager.getMyMemoryState(appProcessInfo) + return appProcessInfo.importance == IMPORTANCE_FOREGROUND || appProcessInfo.importance == IMPORTANCE_VISIBLE + } + + fun NotificationCompat.Builder.update(block: NotificationCompat.Builder.() -> Unit) { + block() + context.notificationManager.notify(Notifications.ID_UPDATER, build()) + } + + companion object { + private const val TAG = "AutoUpdateRunner" + const val ALWAYS = 0 + const val ONLY_ON_UNMETERED = 1 + const val NEVER = 2 + + fun setupTask(context: Context) { + val preferences = Injekt.get() + val restrictions = preferences.shouldAutoUpdate() + val wifiRestriction = if (restrictions == ONLY_ON_UNMETERED) { + NetworkType.UNMETERED + } else { + NetworkType.CONNECTED + } + + val constraints = Constraints.Builder() + .setRequiredNetworkType(wifiRestriction) + .setRequiresDeviceIdle(true) + .build() + + val request = OneTimeWorkRequestBuilder() + .addTag(TAG) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(context) + .enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request) + } + + fun cancelTask(context: Context) { + WorkManager.getInstance(context).cancelAllWorkByTag(TAG) + } + } +} 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/UpdaterJob.kt b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt index 41f9129081..c23f7bd419 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/updater/UpdaterJob.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.data.updater import android.content.Context +import android.os.Build import androidx.core.app.NotificationCompat import androidx.work.Constraints import androidx.work.CoroutineWorker @@ -10,8 +11,10 @@ import androidx.work.PeriodicWorkRequestBuilder import androidx.work.WorkManager import androidx.work.WorkerParameters import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.util.system.notificationManager import kotlinx.coroutines.coroutineScope +import uy.kohesive.injekt.injectLazy import java.util.concurrent.TimeUnit class UpdaterJob(private val context: Context, workerParams: WorkerParameters) : @@ -19,8 +22,14 @@ class UpdaterJob(private val context: Context, workerParams: WorkerParameters) : override suspend fun doWork(): Result = coroutineScope { try { + val preferences: PreferencesHelper by injectLazy() val result = UpdateChecker.getUpdateChecker().checkForUpdate() if (result is UpdateResult.NewUpdate<*>) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && + preferences.shouldAutoUpdate() != AutoUpdaterJob.NEVER + ) { + AutoUpdaterJob.setupTask(context) + } UpdaterNotifier(context).promptUpdate( result.release.info, result.release.downloadLink, 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..aa62a1a3bc 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 @@ -4,9 +4,11 @@ import android.app.PendingIntent import android.content.Context import android.content.Intent import android.net.Uri +import android.os.Build 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 +46,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) @@ -56,10 +59,11 @@ internal class UpdaterNotifier(private val context: Context) { setSmallIcon(android.R.drawable.stat_sys_download_done) color = context.getResourceColor(R.attr.colorAccent) clearActions() + val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S // Download action addAction( android.R.drawable.stat_sys_download_done, - context.getString(R.string.download), + context.getString(if (isOnA12) R.string.update else R.string.download), PendingIntent.getService( context, 0, @@ -155,6 +159,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_notification) + 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 +215,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..81e9189aa1 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,12 @@ 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 androidx.preference.PreferenceManager import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.notification.Notifications @@ -18,12 +21,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 +66,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 +75,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 +93,9 @@ class UpdaterService : Service() { override fun onDestroy() { destroyJob() + if (instance == this) { + instance = null + } super.onDestroy() } @@ -104,7 +112,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 +149,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 +166,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 +261,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/extension/ExtensionInstallNotifier.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallNotifier.kt new file mode 100644 index 0000000000..f2109ff42e --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallNotifier.kt @@ -0,0 +1,62 @@ +package eu.kanade.tachiyomi.extension + +import android.content.Context +import android.graphics.BitmapFactory +import androidx.core.content.ContextCompat +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.NotificationReceiver +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.util.system.notificationBuilder +import eu.kanade.tachiyomi.util.system.notificationManager + +class ExtensionInstallNotifier(private val context: Context) { + + /** + * Bitmap of the app for notifications. + */ + private val notificationBitmap by lazy { + BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher) + } + + /** + * Pending intent of action that cancels the library update + */ + private val cancelIntent by lazy { + NotificationReceiver.cancelExtensionUpdatePendingBroadcast(context) + } + + /** + * Cached progress notification to avoid creating a lot. + */ + val progressNotificationBuilder by lazy { + context.notificationBuilder(Notifications.CHANNEL_UPDATES_TO_EXTS) { + setContentTitle(context.getString(R.string.app_name)) + setSmallIcon(android.R.drawable.stat_sys_download) + setLargeIcon(notificationBitmap) + setContentTitle(context.getString(R.string.updating_extensions)) + setProgress(0, 0, true) + setOngoing(true) + setSilent(true) + setOnlyAlertOnce(true) + color = ContextCompat.getColor(context, R.color.colorAccent) + addAction(R.drawable.ic_close_24dp, context.getString(android.R.string.cancel), cancelIntent) + } + } + + /** + * Shows the notification containing the currently updating manga and the progress. + * + * @param manga the manga that's being updated. + * @param current the current progress. + * @param total the total progress. + */ + fun showProgressNotification(progress: Int, max: Int) { + context.notificationManager.notify( + Notifications.ID_EXTENSION_PROGRESS, + progressNotificationBuilder + .setContentTitle(context.getString(R.string.updating_extensions)) + .setProgress(max, progress, progress == 0) + .build() + ) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallService.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallService.kt new file mode 100644 index 0000000000..26f7ccd80d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionInstallService.kt @@ -0,0 +1,160 @@ +package eu.kanade.tachiyomi.extension + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.os.PowerManager +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.data.notification.Notifications +import eu.kanade.tachiyomi.data.preference.PreferencesHelper +import eu.kanade.tachiyomi.extension.ExtensionManager.ExtensionInfo +import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.util.system.notificationManager +import eu.kanade.tachiyomi.util.system.toast +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Semaphore +import kotlinx.coroutines.sync.withPermit +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get +import java.util.ArrayList +import java.util.concurrent.TimeUnit + +class ExtensionInstallService( + val extensionManager: ExtensionManager = Injekt.get(), +) : Service() { + + /** + * Wake lock that will be held until the service is destroyed. + */ + private lateinit var wakeLock: PowerManager.WakeLock + + private lateinit var notifier: ExtensionInstallNotifier + + private var job: Job? = null + + private var serviceScope = CoroutineScope(Job() + Dispatchers.Default) + + private val requestSemaphore = Semaphore(3) + + private val preferences: PreferencesHelper = Injekt.get() + + /** + * This method needs to be implemented, but it's not used/needed. + */ + override fun onBind(intent: Intent): IBinder? { + return null + } + + /** + * Method called when the service receives an intent. + * + * @param intent the start intent from. + * @param flags the flags of the command. + * @param startId the start id of this command. + * @return the start value of the command. + */ + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + if (intent == null) return START_NOT_STICKY + if (!preferences.hasPromptedBeforeUpdateAll().get()) { + toast(R.string.some_extensions_may_prompt) + preferences.hasPromptedBeforeUpdateAll().set(true) + } + + instance = this + + val list = intent.getParcelableArrayListExtra(KEY_EXTENSION)?.filter { + ( + extensionManager.installedExtensions.find { installed -> + installed.pkgName == it.pkgName + }?.versionCode ?: 0 + ) < it.versionCode + } + ?: return START_NOT_STICKY + var installed = 0 + job = serviceScope.launch { + val results = list.map { + async { + requestSemaphore.withPermit { + extensionManager.installExtension(it, serviceScope) + .collect { + if (it.first.isCompleted()) { + installed++ + } + notifier.showProgressNotification(installed, list.size) + } + } + } + } + results.awaitAll() + } + job?.invokeOnCompletion { stopSelf(startId) } + + return START_REDELIVER_INTENT + } + + /** + * Method called when the service is created. It injects dagger dependencies and acquire + * the wake lock. + */ + override fun onCreate() { + super.onCreate() + notificationManager.cancel(Notifications.ID_UPDATES_TO_EXTS) + notifier = ExtensionInstallNotifier(this) + wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, + "ExtensionInstallService:WakeLock" + ) + wakeLock.acquire(TimeUnit.MINUTES.toMillis(30)) + startForeground(Notifications.ID_EXTENSION_PROGRESS, notifier.progressNotificationBuilder.build()) + } + + /** + * Method called when the service is destroyed. It cancels jobs and releases the wake lock. + */ + override fun onDestroy() { + job?.cancel() + serviceScope.cancel() + if (instance == this) { + instance = null + } + if (wakeLock.isHeld) { + wakeLock.release() + } + super.onDestroy() + } + + companion object { + + private var instance: ExtensionInstallService? = null + + /** + * Stops the service. + * + * @param context the application context. + */ + fun stop(context: Context) { + instance?.serviceScope?.cancel() + context.stopService(Intent(context, ExtensionUpdateJob::class.java)) + } + + /** + * Key that defines what should be updated. + */ + private const val KEY_EXTENSION = "extension" + + fun jobIntent(context: Context, extensions: List): Intent { + return Intent(context, ExtensionInstallService::class.java).apply { + val info = extensions.map(::ExtensionInfo) + putParcelableArrayListExtra(KEY_EXTENSION, ArrayList(info)) + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt index df0fc1f320..828e40c949 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionManager.kt @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension import android.content.Context import android.graphics.drawable.Drawable +import android.os.Parcelable import com.jakewharton.rxrelay.BehaviorRelay import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault @@ -15,9 +16,12 @@ import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo import eu.kanade.tachiyomi.util.system.launchNow +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.withContext +import kotlinx.parcelize.Parcelize import rx.Observable import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -47,6 +51,15 @@ class ExtensionManager( */ private val installer by lazy { ExtensionInstaller(context) } + val downloadRelay + get() = installer.downloadsStateFlow + + fun getExtension(downloadId: Long): String? { + return installer.activeDownloads.entries.find { downloadId == it.value }?.key + } + + fun getActiveInstalls(): Int = installer.activeDownloads.size + /** * Relay used to notify the installed extensions. */ @@ -232,27 +245,14 @@ class ExtensionManager( } /** - * Returns an observable of the installation process for the given extension. It will complete - * once the extension is installed or throws an error. The process will be canceled if - * unsubscribed before its completion. + * Returns a flow of the installation process for the given extension. It will complete + * once the extension is installed or throws an error. The process will be canceled the scope + * is canceled before its completion. * * @param extension The extension to be installed. */ - fun installExtension(extension: Extension.Available): Observable { - return installer.downloadAndInstall(api.getApkUrl(extension), extension) - } - - /** - * Returns an observable of the installation process for the given extension. It will complete - * once the extension is updated or throws an error. The process will be canceled if - * unsubscribed before its completion. - * - * @param extension The extension to be updated. - */ - fun updateExtension(extension: Extension.Installed): Observable { - val availableExt = availableExtensions.find { it.pkgName == extension.pkgName } - ?: return Observable.empty() - return installExtension(availableExt) + suspend fun installExtension(extension: ExtensionInfo, scope: CoroutineScope): Flow { + return installer.downloadAndInstall(api.getApkUrl(extension), extension, scope) } /** @@ -405,6 +405,21 @@ class ExtensionManager( } return this } + + @Parcelize + data class ExtensionInfo( + val apkName: String, + val pkgName: String, + val name: String, + val versionCode: Int, + ) : Parcelable { + constructor(extension: Extension.Available) : this( + apkName = extension.apkName, + pkgName = extension.pkgName, + name = extension.name, + versionCode = extension.versionCode + ) + } } interface ExtensionsChangedListener { diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt index ce9dbe322a..cf7284d066 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/ExtensionUpdateJob.kt @@ -1,6 +1,8 @@ package eu.kanade.tachiyomi.extension +import android.app.PendingIntent import android.content.Context +import android.os.Build import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat @@ -17,6 +19,7 @@ import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.util.system.notification import kotlinx.coroutines.coroutineScope import uy.kohesive.injekt.Injekt @@ -35,15 +38,21 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam } if (pendingUpdates.isNotEmpty()) { - createUpdateNotification(pendingUpdates.map { it.name }) + createUpdateNotification(pendingUpdates) } Result.success() } - private fun createUpdateNotification(names: List) { + private fun createUpdateNotification(extensions: List) { val preferences: PreferencesHelper by injectLazy() - preferences.extensionUpdatesCount().set(names.size) + preferences.extensionUpdatesCount().set(extensions.size) + // Not doing this yet since users will get prompted while device is idle +// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && preferences.autoUpdateExtensions()) { +// val intent = ExtensionInstallService.jobIntent(context, extensions) +// context.startForegroundService(intent) +// return +// } NotificationManagerCompat.from(context).apply { notify( Notifications.ID_UPDATES_TO_EXTS, @@ -51,11 +60,11 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam setContentTitle( context.resources.getQuantityString( R.plurals.extension_updates_available, - names.size, - names.size + extensions.size, + extensions.size ) ) - val extNames = names.joinToString(", ") + val extNames = extensions.joinToString(", ") { it.name } setContentText(extNames) setStyle(NotificationCompat.BigTextStyle().bigText(extNames)) setSmallIcon(R.drawable.ic_extension_update_24dp) @@ -65,6 +74,16 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam context ) ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val intent = ExtensionInstallService.jobIntent(context, extensions) + val pendingIntent = + PendingIntent.getForegroundService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) + addAction( + R.drawable.ic_file_download_24dp, + context.getString(R.string.update_all), + pendingIntent + ) + } setAutoCancel(true) } ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt index a2ff814ce2..c5caeac509 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/api/ExtensionGithubApi.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.extension.api import android.content.Context +import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.util.ExtensionLoader @@ -30,7 +31,7 @@ internal class ExtensionGithubApi { } } - suspend fun checkForUpdates(context: Context): List { + suspend fun checkForUpdates(context: Context): List { return withContext(Dispatchers.IO) { val extensions = findExtensions() @@ -38,7 +39,7 @@ internal class ExtensionGithubApi { .filterIsInstance() .map { it.extension } - val extensionsWithUpdate = mutableListOf() + val extensionsWithUpdate = mutableListOf() val mutInstalledExtensions = installedExtensions.toMutableList() for (installedExt in mutInstalledExtensions) { val pkgName = installedExt.pkgName @@ -46,7 +47,7 @@ internal class ExtensionGithubApi { val hasUpdate = availableExt.versionCode > installedExt.versionCode if (hasUpdate) { - extensionsWithUpdate.add(installedExt) + extensionsWithUpdate.add(availableExt) } } @@ -75,7 +76,7 @@ internal class ExtensionGithubApi { } } - fun getApkUrl(extension: Extension.Available): String { + fun getApkUrl(extension: ExtensionManager.ExtensionInfo): String { return "${REPO_URL_PREFIX}apk/${extension.apkName}" } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt index c50188bb62..299555b6e8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/model/InstallStep.kt @@ -1,9 +1,9 @@ package eu.kanade.tachiyomi.extension.model enum class InstallStep { - Pending, Downloading, Loading, Installing, Installed, Error; + Pending, Downloading, Loading, Installing, Installed, Error, Done; fun isCompleted(): Boolean { - return this == Installed || this == Error + return this == Installed || this == Error || this == Done } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt index e77f2d4092..8d081dd37c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstallActivity.kt @@ -1,10 +1,14 @@ package eu.kanade.tachiyomi.extension.util import android.app.Activity +import android.app.DownloadManager import android.app.PendingIntent +import android.content.Context import android.content.Intent import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller.SessionParams +import android.content.pm.PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED +import android.os.Build import android.os.Bundle import android.widget.Toast import com.hippo.unifile.UniFile @@ -35,10 +39,9 @@ class ExtensionInstallActivity : Activity() { val params = SessionParams( SessionParams.MODE_FULL_INSTALL ) - // TODO: Add once compiling via SDK 31 -// if (Build.VERSION.SDK_INT >= 31) { -// params.setRequireUserAction(USER_ACTION_NOT_REQUIRED) -// } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + params.setRequireUserAction(USER_ACTION_NOT_REQUIRED) + } val sessionId = packageInstaller.createSession(params) val session = packageInstaller.openSession(sessionId) session.openWrite("package", 0, -1).use { packageInSession -> @@ -55,6 +58,9 @@ class ExtensionInstallActivity : Activity() { session.commit(statusReceiver) val extensionManager: ExtensionManager by injectLazy() extensionManager.setInstalling(downloadId, sessionId) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + (getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).remove(downloadId) + } data.close() } catch (error: Exception) { // Either install package can't be found (probably bots) or there's a security exception diff --git a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt index 9b2ceda0d7..937c5df728 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/extension/util/ExtensionInstaller.kt @@ -5,20 +5,35 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter -import android.content.pm.PackageInstaller import android.net.Uri import android.os.Environment import androidx.core.net.toUri -import com.jakewharton.rxrelay.PublishRelay -import eu.kanade.tachiyomi.extension.model.Extension +import eu.kanade.tachiyomi.extension.ExtensionManager import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo import eu.kanade.tachiyomi.util.storage.getUriCompat -import rx.Observable -import rx.android.schedulers.AndroidSchedulers +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapConcat +import kotlinx.coroutines.flow.flattenMerge +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.takeWhile +import kotlinx.coroutines.launch import timber.log.Timber import java.io.File -import java.util.concurrent.TimeUnit /** * The installer which installs, updates and uninstalls the extensions. @@ -30,7 +45,8 @@ internal class ExtensionInstaller(private val context: Context) { /** * The system's download manager */ - private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + private val downloadManager = + context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager /** * The broadcast receiver which listens to download completion events. @@ -41,30 +57,24 @@ internal class ExtensionInstaller(private val context: Context) { * The currently requested downloads, with the package name (unique id) as key, and the id * returned by the download manager. */ - private val activeDownloads = hashMapOf() + val activeDownloads = hashMapOf() /** - * Relay used to notify the installation step of every download. + * StateFlow used to notify the installation step of every download. */ - private val downloadsRelay = PublishRelay.create>() + val downloadsStateFlow = MutableStateFlow(0L to ExtensionIntallInfo(InstallStep.Pending, null)) /** Map of download id to installer session id */ val downloadInstallerMap = hashMapOf() - data class DownloadSessionInfo( - val downloadId: Long, - val session: PackageInstaller.Session, - val sessionId: Int - ) - /** - * Adds the given extension to the downloads queue and returns an observable containing its + * Adds the given extension to the downloads queue and returns a flow containing its * step in the installation process. * * @param url The url of the apk. * @param extension The extension to install. */ - fun downloadAndInstall(url: String, extension: Extension) = Observable.defer { + suspend fun downloadAndInstall(url: String, extension: ExtensionManager.ExtensionInfo, scope: CoroutineScope): Flow { val pkgName = extension.pkgName val oldDownload = activeDownloads[pkgName] @@ -79,77 +89,120 @@ internal class ExtensionInstaller(private val context: Context) { val request = DownloadManager.Request(downloadUri) .setTitle(extension.name) .setMimeType(APK_MIME) - .setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment) + .setDestinationInExternalFilesDir( + context, + Environment.DIRECTORY_DOWNLOADS, + downloadUri.lastPathSegment + ) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) val id = downloadManager.enqueue(request) activeDownloads[pkgName] = id - downloadsRelay.filter { it.first == id } - .map { - val sessionId = downloadInstallerMap[it.first] ?: return@map it.second to null - val session = context.packageManager.packageInstaller.getSessionInfo(sessionId) - it.second to session + scope.launch { + flowOf( + pollStatus(id), + pollInstallStatus(id) + ).flattenMerge() + .transformWhile { + emit(it) + !it.first.isCompleted() + } + .flowOn(Dispatchers.IO) + .catch { e -> + Timber.e(e) + emit(InstallStep.Error to null) + } + .onCompletion { + deleteDownload(pkgName) + } + .collect { + downloadsStateFlow.emit(id to it) + } + } + + return downloadsStateFlow.filter { it.first == id }.map { it.second } + .flowOn(Dispatchers.IO) + .transformWhile { + emit(it) + !it.first.isCompleted() + } + .onCompletion { + deleteDownload(pkgName) } - // Poll download status - .mergeWith(pollStatus(id)) - // Poll installation status - .mergeWith(pollInstallStatus(id)) - // Force an error if the download takes more than 3 minutes - .mergeWith(Observable.timer(3, TimeUnit.MINUTES).map { InstallStep.Error to null }) - // Stop when the application is installed or errors - .takeUntil { it.first.isCompleted() } - // Always notify on main thread - .observeOn(AndroidSchedulers.mainThread()) - // Always remove the download when unsubscribed - .doOnUnsubscribe { deleteDownload(pkgName) } } /** - * Returns an observable that polls the given download id for its status every second, as the + * Returns a flow that polls the given download id for its status every second, as the * manager doesn't have any notification system. It'll stop once the download finishes. * * @param id The id of the download to poll. */ - private fun pollStatus(id: Long): Observable { + private fun pollStatus(id: Long): Flow { val query = DownloadManager.Query().setFilterById(id) - return Observable.interval(0, 1, TimeUnit.SECONDS) - // Get the current download status - .map { - downloadManager.query(query).use { cursor -> - cursor.moveToFirst() - cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + return flow { + while (true) { + val newDownloadState = try { + downloadManager.query(query)?.use { cursor -> + cursor.moveToFirst() + cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) + } + } catch (_: Exception) { } + if (newDownloadState != null) { + emit(newDownloadState) + } + delay(1000) } - // Ignore duplicate results + } .distinctUntilChanged() - // Stop polling when the download fails or finishes - .takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED } - // Map to our model - .flatMap { status -> - val step = when (status) { + .transformWhile { + emit(it) + !(it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED) + } + .flatMapConcat { downloadState -> + val step = when (downloadState) { DownloadManager.STATUS_PENDING -> InstallStep.Pending DownloadManager.STATUS_RUNNING -> InstallStep.Downloading - else -> return@flatMap Observable.empty() + else -> return@flatMapConcat emptyFlow() } - Observable.just(ExtensionIntallInfo(step, null)) - } - .doOnError { - Timber.e(it) + flowOf(ExtensionIntallInfo(step, null)) } } - private fun pollInstallStatus(id: Long): Observable { - return Observable.interval(0, 500, TimeUnit.MILLISECONDS) - .flatMap { - val sessionId = downloadInstallerMap[id] ?: return@flatMap Observable.empty() - val session = context.packageManager.packageInstaller.getSessionInfo(sessionId) - Observable.just(InstallStep.Installing to session) + /** + * Returns a flow that polls the given installer session for its status every half second, as the + * manager doesn't have any notification system. This will only stop once + * + * @param id The id of the download mapped to the session to poll. + */ + private fun pollInstallStatus(id: Long): Flow { + return flow { + while (true) { + val sessionId = downloadInstallerMap[id] + if (sessionId != null) { + val session = + context.packageManager.packageInstaller.getSessionInfo(sessionId) + emit(InstallStep.Installing to session) + } + delay(500) } - .doOnError { + } + .takeWhile { info -> + val sessionId = downloadInstallerMap[id] + if (sessionId != null) { + info.second != null + } else { + true + } + } + .catch { Timber.e(it) } + .onCompletion { + emit(InstallStep.Done to null) + } } /** @@ -185,7 +238,7 @@ internal class ExtensionInstaller(private val context: Context) { * @param downloadId The id of the download. */ fun setInstalling(downloadId: Long, sessionId: Int) { - downloadsRelay.call(downloadId to InstallStep.Installing) + downloadsStateFlow.tryEmit(downloadId to ExtensionIntallInfo(InstallStep.Installing, null)) downloadInstallerMap[downloadId] = sessionId } @@ -204,7 +257,11 @@ internal class ExtensionInstaller(private val context: Context) { fun setInstallationResult(downloadId: Long, result: Boolean) { val step = if (result) InstallStep.Installed else InstallStep.Error downloadInstallerMap.remove(downloadId) - downloadsRelay.call(downloadId to step) + downloadsStateFlow.tryEmit(downloadId to ExtensionIntallInfo(step, null)) + } + + fun softDeleteDownload(downloadId: Long) { + downloadManager.remove(downloadId) } /** @@ -267,10 +324,10 @@ internal class ExtensionInstaller(private val context: Context) { // Set next installation step if (uri != null) { - downloadsRelay.call(id to InstallStep.Loading) + downloadsStateFlow.tryEmit(id to ExtensionIntallInfo(InstallStep.Loading, null)) } else { Timber.e("Couldn't locate downloaded APK") - downloadsRelay.call(id to InstallStep.Error) + downloadsStateFlow.tryEmit(id to ExtensionIntallInfo(InstallStep.Error, null)) return } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt index a0d4dc24b8..2f2c77108e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionAdapter.kt @@ -24,5 +24,6 @@ class ExtensionAdapter(val listener: OnButtonClickListener) : interface OnButtonClickListener { fun onButtonClick(position: Int) fun onCancelClick(position: Int) + fun onUpdateAllClicked(position: Int) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt index 59ff68e33d..5a26ba60d9 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomPresenter.kt @@ -19,12 +19,15 @@ import eu.kanade.tachiyomi.ui.migration.SelectionHeader import eu.kanade.tachiyomi.ui.migration.SourceItem import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.executeOnIO +import eu.kanade.tachiyomi.util.system.withUIContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import rx.Observable import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get @@ -38,7 +41,7 @@ typealias ExtensionIntallInfo = Pair class ExtensionBottomPresenter( private val bottomSheet: ExtensionBottomSheet, private val extensionManager: ExtensionManager = Injekt.get(), - private val preferences: PreferencesHelper = Injekt.get() + val preferences: PreferencesHelper = Injekt.get() ) : BaseCoroutinePresenter(), ExtensionsChangedListener { private var extensions = emptyList() @@ -76,7 +79,10 @@ class ExtensionBottomPresenter( sourceItems = findSourcesWithManga(favs) mangaItems = HashMap( sourceItems.associate { - it.source.id to this@ExtensionBottomPresenter.libraryToMigrationItem(favs, it.source.id) + it.source.id to this@ExtensionBottomPresenter.libraryToMigrationItem( + favs, + it.source.id + ) } ) withContext(Dispatchers.Main) { @@ -89,6 +95,27 @@ class ExtensionBottomPresenter( } listOf(migrationJob, extensionJob).awaitAll() } + presenterScope.launch { + extensionManager.downloadRelay + .collect { + val extPageName = extensionManager.getExtension(it.first) + val extension = extensions.find { item -> + extPageName == item.extension.pkgName + } ?: return@collect + when (it.second.first) { + InstallStep.Installed, InstallStep.Error -> { + currentDownloads.remove(extension.extension.pkgName) + } + else -> { + currentDownloads[extension.extension.pkgName] = it.second + } + } + val item = updateInstallStep(extension.extension, it.second.first, it.second.second) + if (item != null) { + withUIContext { bottomSheet.downloadUpdate(item) } + } + } + } } private fun findSourcesWithManga(library: List): List { @@ -176,7 +203,8 @@ class ExtensionBottomPresenter( updatesSorted.size, updatesSorted.size ), - updatesSorted.size + updatesSorted.size, + items.count { it.extension.pkgName in currentDownloads.keys } != updatesSorted.size ) items += updatesSorted.map { extension -> ExtensionItem(extension, header, currentDownloads[extension.pkgName]) @@ -215,7 +243,7 @@ class ExtensionBottomPresenter( @Synchronized private fun updateInstallStep( extension: Extension, - state: InstallStep, + state: InstallStep?, session: PackageInstaller.SessionInfo? ): ExtensionItem? { val extensions = extensions.toMutableList() @@ -242,13 +270,21 @@ class ExtensionBottomPresenter( fun installExtension(extension: Extension.Available) { if (isNotMIUIOptimized()) { - extensionManager.installExtension(extension).subscribeToInstallUpdate(extension) + presenterScope.launch { + extensionManager.installExtension(ExtensionManager.ExtensionInfo(extension), presenterScope) + .launchIn(this) + } } } fun updateExtension(extension: Extension.Installed) { if (isNotMIUIOptimized()) { - extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension) + val availableExt = + extensionManager.availableExtensions.find { it.pkgName == extension.pkgName } ?: return + presenterScope.launch { + extensionManager.installExtension(ExtensionManager.ExtensionInfo(availableExt), presenterScope) + .launchIn(this) + } } } @@ -260,17 +296,6 @@ class ExtensionBottomPresenter( return true } - private fun Observable.subscribeToInstallUpdate(extension: Extension) { - this.doOnNext { currentDownloads[extension.pkgName] = it } - .doOnUnsubscribe { currentDownloads.remove(extension.pkgName) } - .map { state -> updateInstallStep(extension, state.first, state.second) } - .subscribe { item -> - if (item != null) { - bottomSheet.downloadUpdate(item) - } - } - } - fun uninstallExtension(pkgName: String) { extensionManager.uninstallExtension(pkgName) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt index efd1312963..687362ff82 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionBottomSheet.kt @@ -7,6 +7,7 @@ import android.view.View import android.view.ViewGroup import android.widget.LinearLayout import androidx.recyclerview.widget.RecyclerView +import com.afollestad.materialdialogs.MaterialDialog import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.tabs.TabLayout import eu.davidea.flexibleadapter.FlexibleAdapter @@ -201,6 +202,35 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At presenter.cancelExtensionInstall(extension) } + override fun onUpdateAllClicked(position: Int) { + if (!presenter.preferences.hasPromptedBeforeUpdateAll().get()) { + MaterialDialog(controller.activity!!) + .title(R.string.update_all) + .message(R.string.some_extensions_may_prompt) + .positiveButton(android.R.string.ok) { + presenter.preferences.hasPromptedBeforeUpdateAll().set(true) + updateAllExtensions(position) + } + .show() + } else { + updateAllExtensions(position) + } + } + + fun updateAllExtensions(position: Int) { + val header = (extAdapter?.getSectionHeader(position)) as? ExtensionGroupItem ?: return + val items = extAdapter?.getSectionItemPositions(header) + items?.forEach { + val extItem = (extAdapter?.getItem(it) as? ExtensionItem) ?: return + val extension = (extAdapter?.getItem(it) as? ExtensionItem)?.extension ?: return + if (extItem.installStep == null && + extension is Extension.Installed && extension.hasUpdate + ) { + presenter.updateExtension(extension) + } + } + } + override fun onItemClick(view: View?, position: Int): Boolean { when (binding.tabs.selectedTabPosition) { 0 -> { @@ -298,6 +328,7 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At extAdapter?.updateDataSet(extensions) } updateExtTitle() + updateExtUpdateAllButton() } fun canGoBack(): Boolean { @@ -310,6 +341,20 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At fun downloadUpdate(item: ExtensionItem) { extAdapter?.updateItem(item, item.installStep) + updateExtUpdateAllButton() + } + + fun updateExtUpdateAllButton() { + val updateHeader = + extAdapter?.headerItems?.find { it is ExtensionGroupItem && it.canUpdate != null } as? ExtensionGroupItem + ?: return + val items = extAdapter?.getSectionItemPositions(updateHeader) ?: return + updateHeader.canUpdate = items.any { + val extItem = (extAdapter?.getItem(it) as? ExtensionItem) ?: return + val extension = (extAdapter?.getItem(it) as? ExtensionItem)?.extension ?: return + extItem.installStep == null + } + extAdapter?.updateItem(updateHeader) } override fun trustSignature(signatureHash: String) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt index f6f00096f9..4c16428d90 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupHolder.kt @@ -1,7 +1,9 @@ package eu.kanade.tachiyomi.ui.extension import android.annotation.SuppressLint +import android.os.Build import android.view.View +import androidx.core.view.isVisible import androidx.recyclerview.widget.RecyclerView import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.items.IFlexible @@ -13,8 +15,16 @@ class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter= Build.VERSION_CODES.S + binding.extButton.isEnabled = item.canUpdate == true } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt index 8bd8fc78f4..10cefb51ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionGroupItem.kt @@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.R * @param name The header name. * @param size The number of items in the group. */ -data class ExtensionGroupItem(val name: String, val size: Int) : AbstractHeaderItem() { +data class ExtensionGroupItem(val name: String, val size: Int, var canUpdate: Boolean? = null) : AbstractHeaderItem() { /** * Returns the layout resource of this item. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt index da24759def..3706c5f04f 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/extension/ExtensionHolder.kt @@ -68,6 +68,7 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : @Suppress("ResourceType") fun bindButton(item: ExtensionItem) = with(binding.extButton) { + if (item.installStep == InstallStep.Done) return@with isEnabled = true isClickable = true isActivated = false @@ -87,6 +88,7 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) : InstallStep.Installing -> R.string.installing InstallStep.Installed -> R.string.installed InstallStep.Error -> R.string.retry + else -> return@with } ) if (installStep != InstallStep.Error) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 55e124b954..1c385eabd5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -3,10 +3,12 @@ package eu.kanade.tachiyomi.ui.main import android.animation.AnimatorSet import android.animation.ValueAnimator import android.app.Dialog +import android.app.assist.AssistContent import android.content.Intent import android.graphics.Color import android.graphics.Rect import android.graphics.drawable.Drawable +import android.net.Uri import android.os.Build import android.os.Bundle import android.os.Handler @@ -55,6 +57,7 @@ import eu.kanade.tachiyomi.data.updater.UpdateResult import eu.kanade.tachiyomi.data.updater.UpdaterNotifier import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi +import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.base.controller.BaseController @@ -647,6 +650,25 @@ open class MainActivity : BaseActivity(), DownloadServiceLi return true } + override fun onProvideAssistContent(outContent: AssistContent) { + super.onProvideAssistContent(outContent) + when (val controller = router.backstack.lastOrNull()?.controller) { + is MangaDetailsController -> { + val source = controller.presenter.source as? HttpSource ?: return + val url = try { + source.mangaDetailsRequest(controller.presenter.manga).url.toString() + } catch (e: Exception) { + return + } + outContent.webUri = Uri.parse(url) + } + is BrowseSourceController -> { + val source = controller.presenter.source as? HttpSource ?: return + outContent.webUri = Uri.parse(source.baseUrl) + } + } + } + override fun onDestroy() { super.onDestroy() overflowDialog?.dismiss() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt index 10fc11fadf..f92505024b 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaHeaderHolder.kt @@ -4,6 +4,9 @@ import android.annotation.SuppressLint import android.content.res.ColorStateList import android.graphics.Color import android.view.LayoutInflater +import android.graphics.RenderEffect +import android.graphics.Shader +import android.os.Build import android.view.MotionEvent import android.view.View import androidx.constraintlayout.widget.ConstraintLayout @@ -124,6 +127,15 @@ class MangaHeaderHolder( ) true } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + backdrop.setRenderEffect( + RenderEffect.createBlurEffect( + 10f, + 10f, + Shader.TileMode.MIRROR + ) + ) + } mangaCover.setOnClickListener { adapter.delegate.zoomImageFromThumb(coverCard) } trackButton.setOnClickListener { adapter.delegate.showTrackingSheet() } if (startExpanded) expandDesc() diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt index c137ac181a..62b408a7d7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderActivity.kt @@ -1,6 +1,7 @@ package eu.kanade.tachiyomi.ui.reader import android.annotation.SuppressLint +import android.app.assist.AssistContent import android.content.ClipData import android.content.Context import android.content.Intent @@ -9,6 +10,7 @@ import android.content.res.Configuration import android.graphics.Bitmap import android.graphics.Color import android.graphics.drawable.LayerDrawable +import android.net.Uri import android.os.Build import android.os.Bundle import android.view.KeyEvent @@ -41,6 +43,7 @@ import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn import eu.kanade.tachiyomi.data.preference.toggle import eu.kanade.tachiyomi.databinding.ReaderActivityBinding import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity import eu.kanade.tachiyomi.ui.main.MainActivity @@ -1225,6 +1228,18 @@ class ReaderActivity : startActivity(Intent.createChooser(intent, getString(R.string.share))) } + override fun onProvideAssistContent(outContent: AssistContent) { + super.onProvideAssistContent(outContent) + val manga = presenter.manga ?: return + val source = presenter.source as? HttpSource ?: return + val url = try { + source.mangaDetailsRequest(manga).url.toString() + } catch (e: Exception) { + return + } + outContent.webUri = Uri.parse(url) + } + /** * Called from the page sheet. It delegates saving the image of the given [page] on external * storage to the presenter. diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt index da4db21345..e50384e9f8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/reader/ReaderPresenter.kt @@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.track.DelayedTrackingUpdateJob import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.source.LocalSource +import eu.kanade.tachiyomi.source.Source import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.online.HttpSource @@ -76,6 +77,9 @@ class ReaderPresenter( var manga: Manga? = null private set + val source: Source? + get() = manga?.source?.let { sourceManager.getOrStub(it) } + /** * The chapter id of the currently loaded chapter. Used to restore from process kill. */ 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..e0df1f654c 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 @@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.setting import android.app.Dialog import android.content.Intent +import android.os.Build import android.os.Bundle import androidx.core.net.toUri import androidx.preference.PreferenceScreen @@ -190,15 +191,16 @@ class AboutController : SettingsController() { ) override fun onCreateDialog(savedViewState: Bundle?): Dialog { + val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S return MaterialDialog(activity!!) .title(R.string.new_version_available) .message(text = args.getString(BODY_KEY) ?: "") - .positiveButton(R.string.download) { + .positiveButton(if (isOnA12) R.string.update else R.string.download) { val appContext = applicationContext 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/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt index 678fd27699..a9f1a0830a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsGeneralController.kt @@ -1,5 +1,6 @@ package eu.kanade.tachiyomi.ui.setting +import android.os.Build import android.os.Bundle import android.view.View import androidx.appcompat.app.AppCompatDelegate @@ -8,6 +9,7 @@ import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn +import eu.kanade.tachiyomi.data.updater.AutoUpdaterJob import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.appDelegateNightMode @@ -270,6 +272,20 @@ class SettingsGeneralController : SettingsController() { defaultValue = "" } } + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isUpdaterEnabled) { + preferenceCategory { + titleRes = R.string.auto_updates + + intListPreference(activity) { + key = Keys.shouldAutoUpdate + titleRes = R.string.auto_update_app + entryRange = 0..2 + entriesRes = arrayOf(R.string.over_any_network, R.string.over_wifi_only, R.string.dont_auto_update) + defaultValue = AutoUpdaterJob.ONLY_ON_UNMETERED + } + } + } } override fun onDestroyView(view: View) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ThemePreference.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ThemePreference.kt index 43c9f10972..be21d53f76 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ThemePreference.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/ThemePreference.kt @@ -4,6 +4,7 @@ import android.app.Activity import android.content.Context import android.content.res.ColorStateList import android.graphics.Color +import android.os.Build import android.util.AttributeSet import android.view.View import androidx.appcompat.app.AppCompatDelegate @@ -26,6 +27,7 @@ import eu.kanade.tachiyomi.databinding.ThemesPreferenceBinding import eu.kanade.tachiyomi.util.system.ThemeUtil import eu.kanade.tachiyomi.util.system.Themes import eu.kanade.tachiyomi.util.system.appDelegateNightMode +import eu.kanade.tachiyomi.util.system.contextCompatColor import eu.kanade.tachiyomi.util.system.dpToPx import eu.kanade.tachiyomi.util.system.isInNightMode import uy.kohesive.injekt.injectLazy @@ -56,14 +58,15 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu selectExtensionLight = fastAdapterLight.getSelectExtension().setThemeListener(false) selectExtensionDark = fastAdapterDark.getSelectExtension().setThemeListener(true) val enumConstants = Themes.values() + val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S itemAdapterLight.set( enumConstants - .filter { !it.isDarkTheme || it.followsSystem } + .filter { (!it.isDarkTheme || it.followsSystem) && (it.styleRes != R.style.Theme_Tachiyomi_Monet || isOnA12) } .map { ThemeItem(it, false) } ) itemAdapterDark.set( enumConstants - .filter { it.isDarkTheme || it.followsSystem } + .filter { (it.isDarkTheme || it.followsSystem) && (it.styleRes != R.style.Theme_Tachiyomi_Monet || isOnA12) } .map { ThemeItem(it, true) } ) isSelectable = false @@ -207,6 +210,7 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu inner class ViewHolder(view: View) : FastAdapter.ViewHolder(view) { val binding = ThemeItemBinding.bind(view) + override fun bindView(item: ThemeItem, payloads: List) { binding.themeNameText.setText( if (item.isDarkTheme) { @@ -228,27 +232,75 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu binding.themeSelected.alpha = if (themeMatchesApp) 1f else 0.5f binding.checkbox.alpha = if (themeMatchesApp) 1f else 0.5f } - binding.themeToolbar.setBackgroundColor(item.colors.appBar) - binding.themeAppBarText.imageTintList = - ColorStateList.valueOf(item.colors.appBarText) - binding.themeHeroImage.imageTintList = - ColorStateList.valueOf(item.colors.primaryText) - binding.themePrimaryText.imageTintList = - ColorStateList.valueOf(item.colors.primaryText) - binding.themeAccentedButton.imageTintList = - ColorStateList.valueOf(item.colors.colorAccent) - binding.themeSecondaryText.imageTintList = - ColorStateList.valueOf(item.colors.secondaryText) - binding.themeSecondaryText2.imageTintList = - ColorStateList.valueOf(item.colors.secondaryText) + if (item.theme.styleRes == R.style.Theme_Tachiyomi_Monet && + Build.VERSION.SDK_INT >= Build.VERSION_CODES.S + ) { + val nightMode = item.isDarkTheme + val appBar = context.contextCompatColor( + if (nightMode) android.R.color.system_neutral1_900 + else android.R.color.system_neutral1_50 + ) + val appBarText = context.contextCompatColor( + if (nightMode) android.R.color.system_accent2_10 + else android.R.color.system_accent2_800 + ) + val colorAccent = context.contextCompatColor( + if (nightMode) android.R.color.system_accent1_300 + else android.R.color.system_accent1_500 + ) + val bottomBar = context.contextCompatColor( + if (nightMode) android.R.color.system_neutral1_800 + else android.R.color.system_accent2_100 + ) + val colorBackground = context.contextCompatColor( + if (nightMode) android.R.color.system_neutral1_900 + else android.R.color.system_neutral1_50 + ) - binding.themeBottomBar.setBackgroundColor(item.colors.bottomBar) - binding.themeItem1.imageTintList = - ColorStateList.valueOf(item.colors.inactiveTab) - binding.themeItem2.imageTintList = ColorStateList.valueOf(item.colors.activeTab) - binding.themeItem3.imageTintList = - ColorStateList.valueOf(item.colors.inactiveTab) - binding.themeLayout.setBackgroundColor(item.colors.colorBackground) + binding.themeToolbar.setBackgroundColor(appBar) + binding.themeAppBarText.imageTintList = + ColorStateList.valueOf(appBarText) + binding.themeHeroImage.imageTintList = + ColorStateList.valueOf(item.colors.primaryText) + binding.themePrimaryText.imageTintList = + ColorStateList.valueOf(item.colors.primaryText) + binding.themeAccentedButton.imageTintList = + ColorStateList.valueOf(colorAccent) + binding.themeSecondaryText.imageTintList = + ColorStateList.valueOf(item.colors.secondaryText) + binding.themeSecondaryText2.imageTintList = + ColorStateList.valueOf(item.colors.secondaryText) + + binding.themeBottomBar.setBackgroundColor(bottomBar) + binding.themeItem1.imageTintList = + ColorStateList.valueOf(item.colors.inactiveTab) + binding.themeItem2.imageTintList = ColorStateList.valueOf(colorAccent) + binding.themeItem3.imageTintList = + ColorStateList.valueOf(item.colors.inactiveTab) + binding.themeLayout.setBackgroundColor(colorBackground) + } else { + binding.themeToolbar.setBackgroundColor(item.colors.appBar) + binding.themeAppBarText.imageTintList = + ColorStateList.valueOf(item.colors.appBarText) + binding.themeHeroImage.imageTintList = + ColorStateList.valueOf(item.colors.primaryText) + binding.themePrimaryText.imageTintList = + ColorStateList.valueOf(item.colors.primaryText) + binding.themeAccentedButton.imageTintList = + ColorStateList.valueOf(item.colors.colorAccent) + binding.themeSecondaryText.imageTintList = + ColorStateList.valueOf(item.colors.secondaryText) + binding.themeSecondaryText2.imageTintList = + ColorStateList.valueOf(item.colors.secondaryText) + + binding.themeBottomBar.setBackgroundColor(item.colors.bottomBar) + binding.themeItem1.imageTintList = + ColorStateList.valueOf(item.colors.inactiveTab) + binding.themeItem2.imageTintList = ColorStateList.valueOf(item.colors.activeTab) + binding.themeItem3.imageTintList = + ColorStateList.valueOf(item.colors.inactiveTab) + binding.themeLayout.setBackgroundColor(item.colors.colorBackground) + } if (item.isDarkTheme && preferences.themeDarkAmoled().get()) { binding.themeLayout.setBackgroundColor(Color.BLACK) if (!ThemeUtil.isColoredTheme(item.theme)) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt b/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt index cecf938f2e..2b4bb83de0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/system/Themes.kt @@ -11,6 +11,12 @@ import kotlin.math.roundToInt @Suppress("unused") enum class Themes(@StyleRes val styleRes: Int, val nightMode: Int, @StringRes val nameRes: Int, @StringRes altNameRes: Int? = null) { + MONET( + R.style.Theme_Tachiyomi_Monet, + AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, + R.string.a_brighter_you, + R.string.a_calmer_you + ), DEFAULT( R.style.Theme_Tachiyomi, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, diff --git a/app/src/main/res/color/accent_text_btn_color_selector.xml b/app/src/main/res/color/accent_text_btn_color_selector.xml new file mode 100644 index 0000000000..3c44c75b60 --- /dev/null +++ b/app/src/main/res/color/accent_text_btn_color_selector.xml @@ -0,0 +1,7 @@ + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/anim_tachij2k_splash.xml b/app/src/main/res/drawable/anim_tachij2k_splash.xml new file mode 100644 index 0000000000..5543b6ce0a --- /dev/null +++ b/app/src/main/res/drawable/anim_tachij2k_splash.xml @@ -0,0 +1,171 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/extension_card_header.xml b/app/src/main/res/layout/extension_card_header.xml index 3b5d2c1763..416b0be1ac 100644 --- a/app/src/main/res/layout/extension_card_header.xml +++ b/app/src/main/res/layout/extension_card_header.xml @@ -24,6 +24,25 @@ android:layout_marginTop="20dp" tools:text="Title"/> + + diff --git a/app/src/main/res/values-night-v31/themes.xml b/app/src/main/res/values-night-v31/themes.xml new file mode 100644 index 0000000000..eed409b7ec --- /dev/null +++ b/app/src/main/res/values-night-v31/themes.xml @@ -0,0 +1,15 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 16dabf1ce0..3921fd9b2f 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -18,7 +18,7 @@ @color/md_white_1000_12 @color/material_green_700 #cc4444 - + @color/md_white_1000 #1C1C1D #212121 diff --git a/app/src/main/res/values-v31/themes.xml b/app/src/main/res/values-v31/themes.xml new file mode 100644 index 0000000000..882a83cb4f --- /dev/null +++ b/app/src/main/res/values-v31/themes.xml @@ -0,0 +1,14 @@ + + + + \ No newline at end of file diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 7d1720c093..741b0db70e 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -34,6 +34,7 @@ @color/md_grey_50 @color/md_white_1000 + @color/md_black_1000 #DE000000 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9467ff6ff4..c18f34433b 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 @@ -312,6 +314,9 @@ App info %1$s must be enabled first Could not install extension + Update all + Some extensions may still prompt to be installed first. + Updating extensions %d update pending %d updates pending @@ -625,6 +630,8 @@ Midnight Dusk Spring Blossom Strawberry Daiquiri + A Brighter You + A Calmer You Yotsuba Yin Yang @@ -651,6 +658,11 @@ Starting screen Back to start Pressing back to starting screen + Auto-updates + Auto-update app + Over Wi-Fi only + Over any network + Don\'t auto-update App shortcuts Show recently used sources @@ -980,5 +992,6 @@ Use default View all errors View chapters + Warning Wi-Fi diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index 6ae4156e8d..623c32113b 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -10,6 +10,9 @@ false false true + @color/colorPrimary + @drawable/anim_tachij2k_splash + 775 @color/app_color_primary @color/colorPrimary @color/background @@ -105,6 +108,7 @@ @color/colorAccentYinyangText @color/colorOnAccentYinyang +