Merge pull request #936 from Jays2Kings/Android-12-Features

Android 12 features
This commit is contained in:
Jays2Kings 2021-07-19 00:32:01 -04:00 committed by GitHub
commit f855c10d42
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 1276 additions and 162 deletions

View File

@ -200,10 +200,22 @@
android:name=".data.notification.NotificationReceiver" android:name=".data.notification.NotificationReceiver"
android:exported="false" /> android:exported="false" />
<receiver
android:name=".data.updater.UpdaterBroadcast"
android:exported="false">
<intent-filter>
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
</intent-filter>
</receiver>
<service <service
android:name=".data.library.LibraryUpdateService" android:name=".data.library.LibraryUpdateService"
android:exported="false" /> android:exported="false" />
<service
android:name=".extension.ExtensionInstallService"
android:exported="false" />
<service <service
android:name=".data.download.DownloadService" android:name=".data.download.DownloadService"
android:exported="false" /> android:exported="false" />

View File

@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.updater.UpdaterJob import eu.kanade.tachiyomi.data.updater.UpdaterJob
import eu.kanade.tachiyomi.data.updater.UpdaterService
import eu.kanade.tachiyomi.extension.ExtensionUpdateJob import eu.kanade.tachiyomi.extension.ExtensionUpdateJob
import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE import eu.kanade.tachiyomi.network.PREF_DOH_CLOUDFLARE
import eu.kanade.tachiyomi.ui.library.LibraryPresenter import eu.kanade.tachiyomi.ui.library.LibraryPresenter
@ -29,6 +30,10 @@ object Migrations {
*/ */
fun upgrade(preferences: PreferencesHelper): Boolean { fun upgrade(preferences: PreferencesHelper): Boolean {
val context = preferences.context val context = preferences.context
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
prefs.edit {
remove(UpdaterService.NOTIFY_ON_INSTALL_KEY)
}
val oldVersion = preferences.lastVersionCode().getOrDefault() val oldVersion = preferences.lastVersionCode().getOrDefault()
if (oldVersion < BuildConfig.VERSION_CODE) { if (oldVersion < BuildConfig.VERSION_CODE) {
preferences.lastVersionCode().set(BuildConfig.VERSION_CODE) preferences.lastVersionCode().set(BuildConfig.VERSION_CODE)
@ -103,7 +108,6 @@ object Migrations {
} }
if (oldVersion < 71) { if (oldVersion < 71) {
// Migrate DNS over HTTPS setting // Migrate DNS over HTTPS setting
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val wasDohEnabled = prefs.getBoolean("enable_doh", false) val wasDohEnabled = prefs.getBoolean("enable_doh", false)
if (wasDohEnabled) { if (wasDohEnabled) {
prefs.edit { prefs.edit {
@ -114,7 +118,6 @@ object Migrations {
} }
if (oldVersion < 73) { if (oldVersion < 73) {
// Reset rotation to Free after replacing Lock // Reset rotation to Free after replacing Lock
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
if (prefs.contains("pref_rotation_type_key")) { if (prefs.contains("pref_rotation_type_key")) {
prefs.edit { prefs.edit {
putInt("pref_rotation_type_key", 1) putInt("pref_rotation_type_key", 1)
@ -128,7 +131,6 @@ object Migrations {
} }
} }
if (oldVersion < 75) { if (oldVersion < 75) {
val prefs = PreferenceManager.getDefaultSharedPreferences(context)
val wasShortcutsDisabled = !prefs.getBoolean("show_manga_app_shortcuts", true) val wasShortcutsDisabled = !prefs.getBoolean("show_manga_app_shortcuts", true)
if (wasShortcutsDisabled) { if (wasShortcutsDisabled) {
prefs.edit { prefs.edit {
@ -149,7 +151,6 @@ object Migrations {
} }
if (oldVersion < 77) { if (oldVersion < 77) {
// Migrate Rotation and Viewer values to default values for viewer_flags // 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)) { val newOrientation = when (prefs.getInt("pref_rotation_type_key", 1)) {
1 -> OrientationType.FREE.flagValue 1 -> OrientationType.FREE.flagValue
2 -> OrientationType.PORTRAIT.flagValue 2 -> OrientationType.PORTRAIT.flagValue

View File

@ -19,6 +19,7 @@ import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateService import eu.kanade.tachiyomi.data.library.LibraryUpdateService
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.updater.UpdaterService import eu.kanade.tachiyomi.data.updater.UpdaterService
import eu.kanade.tachiyomi.extension.ExtensionInstallService
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.ui.manga.MangaDetailsController
@ -71,6 +72,7 @@ class NotificationReceiver : BroadcastReceiver() {
) )
// Cancel library update and dismiss notification // Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
ACTION_CANCEL_EXTENSION_UPDATE -> cancelExtensionUpdate(context)
ACTION_CANCEL_UPDATE_DOWNLOAD -> cancelDownloadUpdate(context) ACTION_CANCEL_UPDATE_DOWNLOAD -> cancelDownloadUpdate(context)
ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context) ACTION_CANCEL_RESTORE -> cancelRestoreUpdate(context)
// Share backup file // Share backup file
@ -199,6 +201,17 @@ class NotificationReceiver : BroadcastReceiver() {
Handler().post { dismissNotification(context, Notifications.ID_LIBRARY_PROGRESS) } 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 * Method called when user wants to mark as read
* *
@ -251,6 +264,9 @@ class NotificationReceiver : BroadcastReceiver() {
// Called to cancel library update. // Called to cancel library update.
private const val ACTION_CANCEL_LIBRARY_UPDATE = "$ID.$NAME.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" private const val ACTION_CANCEL_UPDATE_DOWNLOAD = "$ID.$NAME.CANCEL_UPDATE_DOWNLOAD"
// Called to mark as read // Called to mark as read
@ -568,6 +584,19 @@ class NotificationReceiver : BroadcastReceiver() {
return PendingIntent.getBroadcast(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) 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 * Returns [PendingIntent] that cancels the download for a Tachiyomi update
* *

View File

@ -20,6 +20,8 @@ object Notifications {
const val ID_UPDATER = 1 const val ID_UPDATER = 1
const val ID_DOWNLOAD_IMAGE = 2 const val ID_DOWNLOAD_IMAGE = 2
const val ID_INSTALL = 3 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. * 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 CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel"
const val ID_UPDATES_TO_EXTS = -401 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" private const val GROUP_BACKUP_RESTORE = "group_backup_restore"
const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel" const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel"
const val ID_RESTORE_PROGRESS = -501 const val ID_RESTORE_PROGRESS = -501
@ -135,5 +140,25 @@ object Notifications {
) )
) )
context.notificationManager.createNotificationChannels(channels) 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)
}
} }
} }

View File

@ -229,6 +229,8 @@ object PreferenceKeys {
const val incognitoMode = "incognito_mode" const val incognitoMode = "incognito_mode"
const val shouldAutoUpdate = "should_auto_update"
const val defaultChapterFilterByRead = "default_chapter_filter_by_read" const val defaultChapterFilterByRead = "default_chapter_filter_by_read"
const val defaultChapterFilterByDownloaded = "default_chapter_filter_by_downloaded" const val defaultChapterFilterByDownloaded = "default_chapter_filter_by_downloaded"

View File

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.preference
import android.content.Context import android.content.Context
import android.content.SharedPreferences import android.content.SharedPreferences
import android.net.Uri import android.net.Uri
import android.os.Build
import android.os.Environment import android.os.Environment
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
import androidx.core.content.edit import androidx.core.content.edit
@ -13,6 +14,7 @@ import com.tfcporciuncula.flow.FlowSharedPreferences
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.track.TrackService 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.library.filter.FilterBottomSheet
import eu.kanade.tachiyomi.ui.reader.settings.OrientationType import eu.kanade.tachiyomi.ui.reader.settings.OrientationType
import eu.kanade.tachiyomi.ui.reader.settings.PageLayout 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 themeDarkAmoled() = flowPrefs.getBoolean(Keys.themeDarkAmoled, false)
fun lightTheme() = flowPrefs.getEnum(Keys.lightTheme, Themes.DEFAULT) val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
fun darkTheme() = flowPrefs.getEnum(Keys.darkTheme, Themes.DEFAULT) 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) fun pageTransitions() = flowPrefs.getBoolean(Keys.enableTransitions, true)
@ -424,6 +427,10 @@ class PreferencesHelper(val context: Context) {
fun incognitoMode() = flowPrefs.getBoolean(Keys.incognitoMode, false) 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 filterChapterByRead() = flowPrefs.getInt(Keys.defaultChapterFilterByRead, Manga.SHOW_ALL)
fun filterChapterByDownloaded() = flowPrefs.getInt(Keys.defaultChapterFilterByDownloaded, Manga.SHOW_ALL) fun filterChapterByDownloaded() = flowPrefs.getInt(Keys.defaultChapterFilterByDownloaded, Manga.SHOW_ALL)

View File

@ -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<PreferencesHelper>()
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<AutoUpdaterJob>()
.addTag(TAG)
.setConstraints(constraints)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(TAG, ExistingWorkPolicy.REPLACE, request)
}
fun cancelTask(context: Context) {
WorkManager.getInstance(context).cancelAllWorkByTag(TAG)
}
}
}

View File

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

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.data.updater package eu.kanade.tachiyomi.data.updater
import android.content.Context import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.work.Constraints import androidx.work.Constraints
import androidx.work.CoroutineWorker import androidx.work.CoroutineWorker
@ -10,8 +11,10 @@ import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager import androidx.work.WorkManager
import androidx.work.WorkerParameters import androidx.work.WorkerParameters
import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.util.system.notificationManager import eu.kanade.tachiyomi.util.system.notificationManager
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import uy.kohesive.injekt.injectLazy
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class UpdaterJob(private val context: Context, workerParams: WorkerParameters) : 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 { override suspend fun doWork(): Result = coroutineScope {
try { try {
val preferences: PreferencesHelper by injectLazy()
val result = UpdateChecker.getUpdateChecker().checkForUpdate() val result = UpdateChecker.getUpdateChecker().checkForUpdate()
if (result is UpdateResult.NewUpdate<*>) { if (result is UpdateResult.NewUpdate<*>) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S &&
preferences.shouldAutoUpdate() != AutoUpdaterJob.NEVER
) {
AutoUpdaterJob.setupTask(context)
}
UpdaterNotifier(context).promptUpdate( UpdaterNotifier(context).promptUpdate(
result.release.info, result.release.info,
result.release.downloadLink, result.release.downloadLink,

View File

@ -4,9 +4,11 @@ import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.net.Uri import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.NotificationHandler import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.notification.NotificationReceiver 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) { fun promptUpdate(body: String, url: String, releaseUrl: String) {
val intent = Intent(context, UpdaterService::class.java).apply { val intent = Intent(context, UpdaterService::class.java).apply {
putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url) putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url)
putExtra(UpdaterService.EXTRA_NOTIFY_ON_INSTALL, true)
} }
val pendingIntent = NotificationReceiver.openUpdatePendingActivity(context, body, url) 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) setSmallIcon(android.R.drawable.stat_sys_download_done)
color = context.getResourceColor(R.attr.colorAccent) color = context.getResourceColor(R.attr.colorAccent)
clearActions() clearActions()
val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
// Download action // Download action
addAction( addAction(
android.R.drawable.stat_sys_download_done, 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( PendingIntent.getService(
context, context,
0, 0,
@ -155,6 +159,31 @@ internal class UpdaterNotifier(private val context: Context) {
notificationBuilder.show(Notifications.ID_INSTALL) 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 * Call when apk download throws a error
* *
@ -186,6 +215,32 @@ internal class UpdaterNotifier(private val context: Context) {
notificationBuilder.show(Notifications.ID_UPDATER) 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() { fun cancel() {
NotificationReceiver.dismissNotification(context, Notifications.ID_UPDATER) NotificationReceiver.dismissNotification(context, Notifications.ID_UPDATER)
} }

View File

@ -4,9 +4,12 @@ import android.app.PendingIntent
import android.app.Service import android.app.Service
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build import android.os.Build
import android.os.IBinder import android.os.IBinder
import android.os.PowerManager import android.os.PowerManager
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import eu.kanade.tachiyomi.BuildConfig import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications 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.getUriCompat
import eu.kanade.tachiyomi.util.storage.saveTo import eu.kanade.tachiyomi.util.storage.saveTo
import eu.kanade.tachiyomi.util.system.acquireWakeLock 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.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import okhttp3.Call import okhttp3.Call
import okhttp3.internal.http2.ErrorCode import okhttp3.internal.http2.ErrorCode
@ -64,6 +66,8 @@ class UpdaterService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) return START_NOT_STICKY if (intent == null) return START_NOT_STICKY
instance = this
val handler = CoroutineExceptionHandler { _, exception -> val handler = CoroutineExceptionHandler { _, exception ->
Timber.e(exception) Timber.e(exception)
stopSelf(startId) stopSelf(startId)
@ -71,9 +75,10 @@ class UpdaterService : Service() {
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return START_NOT_STICKY val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return START_NOT_STICKY
val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name) 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) { runningJob = GlobalScope.launch(handler) {
downloadApk(title, url) downloadApk(title, url, notifyOnInstall)
} }
runningJob?.invokeOnCompletion { stopSelf(startId) } runningJob?.invokeOnCompletion { stopSelf(startId) }
@ -88,6 +93,9 @@ class UpdaterService : Service() {
override fun onDestroy() { override fun onDestroy() {
destroyJob() destroyJob()
if (instance == this) {
instance = null
}
super.onDestroy() super.onDestroy()
} }
@ -104,7 +112,7 @@ class UpdaterService : Service() {
* *
* @param url url location of file * @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. // Show notification download starting.
notifier.onDownloadStarted(title) notifier.onDownloadStarted(title)
@ -141,7 +149,11 @@ class UpdaterService : Service() {
response.close() response.close()
throw Exception("Unsuccessful response") throw Exception("Unsuccessful response")
} }
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
startInstalling(apkFile, notifyOnInstall)
} else {
notifier.onDownloadFinished(apkFile.getUriCompat(this)) notifier.onDownloadFinished(apkFile.getUriCompat(this))
}
} catch (error: Exception) { } catch (error: Exception) {
Timber.e(error) Timber.e(error)
if (error is CancellationException || 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 { 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_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 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. * Returns the status of the service.
* *
* @param context the application context. * @param context the application context.
* @return true if the service is running, false otherwise. * @return true if the service is running, false otherwise.
*/ */
private fun isRunning(context: Context): Boolean = fun isRunning(): Boolean = instance != null
context.isServiceRunning(UpdaterService::class.java)
/** /**
* Downloads a new update and let the user install the new version from a notification. * Downloads a new update and let the user install the new version from a notification.
* @param context the application context. * @param context the application context.
* @param url the url to the new update. * @param url the url to the new update.
*/ */
fun start(context: Context, url: String, title: String = context.getString(R.string.app_name)) { fun start(context: Context, url: String, notifyOnInstall: Boolean) {
if (!isRunning(context)) { if (!isRunning()) {
val title = context.getString(R.string.app_name)
val intent = Intent(context, UpdaterService::class.java).apply { val intent = Intent(context, UpdaterService::class.java).apply {
putExtra(EXTRA_DOWNLOAD_TITLE, title) putExtra(EXTRA_DOWNLOAD_TITLE, title)
putExtra(EXTRA_DOWNLOAD_URL, url) putExtra(EXTRA_DOWNLOAD_URL, url)
putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall)
} }
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
context.startService(intent) context.startService(intent)
@ -202,9 +261,10 @@ class UpdaterService : Service() {
* @param url the url to the new update. * @param url the url to the new update.
* @return [PendingIntent] * @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 { val intent = Intent(context, UpdaterService::class.java).apply {
putExtra(EXTRA_DOWNLOAD_URL, url) putExtra(EXTRA_DOWNLOAD_URL, url)
putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall)
} }
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT) return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
} }

View File

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

View File

@ -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<ExtensionInfo>(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<Extension.Available>): Intent {
return Intent(context, ExtensionInstallService::class.java).apply {
val info = extensions.map(::ExtensionInfo)
putParcelableArrayListExtra(KEY_EXTENSION, ArrayList(info))
}
}
}
}

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.extension
import android.content.Context import android.content.Context
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.os.Parcelable
import com.jakewharton.rxrelay.BehaviorRelay import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault 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.source.SourceManager
import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo
import eu.kanade.tachiyomi.util.system.launchNow import eu.kanade.tachiyomi.util.system.launchNow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import rx.Observable import rx.Observable
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -47,6 +51,15 @@ class ExtensionManager(
*/ */
private val installer by lazy { ExtensionInstaller(context) } 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. * 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 * 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 if * once the extension is installed or throws an error. The process will be canceled the scope
* unsubscribed before its completion. * is canceled before its completion.
* *
* @param extension The extension to be installed. * @param extension The extension to be installed.
*/ */
fun installExtension(extension: Extension.Available): Observable<ExtensionIntallInfo> { suspend fun installExtension(extension: ExtensionInfo, scope: CoroutineScope): Flow<ExtensionIntallInfo> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension) return installer.downloadAndInstall(api.getApkUrl(extension), extension, scope)
}
/**
* 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<ExtensionIntallInfo> {
val availableExt = availableExtensions.find { it.pkgName == extension.pkgName }
?: return Observable.empty()
return installExtension(availableExt)
} }
/** /**
@ -405,6 +405,21 @@ class ExtensionManager(
} }
return this 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 { interface ExtensionsChangedListener {

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.extension package eu.kanade.tachiyomi.extension
import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat 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.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.util.system.notification import eu.kanade.tachiyomi.util.system.notification
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
@ -35,15 +38,21 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
} }
if (pendingUpdates.isNotEmpty()) { if (pendingUpdates.isNotEmpty()) {
createUpdateNotification(pendingUpdates.map { it.name }) createUpdateNotification(pendingUpdates)
} }
Result.success() Result.success()
} }
private fun createUpdateNotification(names: List<String>) { private fun createUpdateNotification(extensions: List<Extension.Available>) {
val preferences: PreferencesHelper by injectLazy() 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 { NotificationManagerCompat.from(context).apply {
notify( notify(
Notifications.ID_UPDATES_TO_EXTS, Notifications.ID_UPDATES_TO_EXTS,
@ -51,11 +60,11 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
setContentTitle( setContentTitle(
context.resources.getQuantityString( context.resources.getQuantityString(
R.plurals.extension_updates_available, R.plurals.extension_updates_available,
names.size, extensions.size,
names.size extensions.size
) )
) )
val extNames = names.joinToString(", ") val extNames = extensions.joinToString(", ") { it.name }
setContentText(extNames) setContentText(extNames)
setStyle(NotificationCompat.BigTextStyle().bigText(extNames)) setStyle(NotificationCompat.BigTextStyle().bigText(extNames))
setSmallIcon(R.drawable.ic_extension_update_24dp) setSmallIcon(R.drawable.ic_extension_update_24dp)
@ -65,6 +74,16 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
context 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) setAutoCancel(true)
} }
) )

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.extension.api package eu.kanade.tachiyomi.extension.api
import android.content.Context import android.content.Context
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.LoadResult import eu.kanade.tachiyomi.extension.model.LoadResult
import eu.kanade.tachiyomi.extension.util.ExtensionLoader import eu.kanade.tachiyomi.extension.util.ExtensionLoader
@ -30,7 +31,7 @@ internal class ExtensionGithubApi {
} }
} }
suspend fun checkForUpdates(context: Context): List<Extension.Installed> { suspend fun checkForUpdates(context: Context): List<Extension.Available> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
val extensions = findExtensions() val extensions = findExtensions()
@ -38,7 +39,7 @@ internal class ExtensionGithubApi {
.filterIsInstance<LoadResult.Success>() .filterIsInstance<LoadResult.Success>()
.map { it.extension } .map { it.extension }
val extensionsWithUpdate = mutableListOf<Extension.Installed>() val extensionsWithUpdate = mutableListOf<Extension.Available>()
val mutInstalledExtensions = installedExtensions.toMutableList() val mutInstalledExtensions = installedExtensions.toMutableList()
for (installedExt in mutInstalledExtensions) { for (installedExt in mutInstalledExtensions) {
val pkgName = installedExt.pkgName val pkgName = installedExt.pkgName
@ -46,7 +47,7 @@ internal class ExtensionGithubApi {
val hasUpdate = availableExt.versionCode > installedExt.versionCode val hasUpdate = availableExt.versionCode > installedExt.versionCode
if (hasUpdate) { 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}" return "${REPO_URL_PREFIX}apk/${extension.apkName}"
} }

View File

@ -1,9 +1,9 @@
package eu.kanade.tachiyomi.extension.model package eu.kanade.tachiyomi.extension.model
enum class InstallStep { enum class InstallStep {
Pending, Downloading, Loading, Installing, Installed, Error; Pending, Downloading, Loading, Installing, Installed, Error, Done;
fun isCompleted(): Boolean { fun isCompleted(): Boolean {
return this == Installed || this == Error return this == Installed || this == Error || this == Done
} }
} }

View File

@ -1,10 +1,14 @@
package eu.kanade.tachiyomi.extension.util package eu.kanade.tachiyomi.extension.util
import android.app.Activity import android.app.Activity
import android.app.DownloadManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageInstaller import android.content.pm.PackageInstaller
import android.content.pm.PackageInstaller.SessionParams 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.os.Bundle
import android.widget.Toast import android.widget.Toast
import com.hippo.unifile.UniFile import com.hippo.unifile.UniFile
@ -35,10 +39,9 @@ class ExtensionInstallActivity : Activity() {
val params = SessionParams( val params = SessionParams(
SessionParams.MODE_FULL_INSTALL SessionParams.MODE_FULL_INSTALL
) )
// TODO: Add once compiling via SDK 31 if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
// if (Build.VERSION.SDK_INT >= 31) { params.setRequireUserAction(USER_ACTION_NOT_REQUIRED)
// params.setRequireUserAction(USER_ACTION_NOT_REQUIRED) }
// }
val sessionId = packageInstaller.createSession(params) val sessionId = packageInstaller.createSession(params)
val session = packageInstaller.openSession(sessionId) val session = packageInstaller.openSession(sessionId)
session.openWrite("package", 0, -1).use { packageInSession -> session.openWrite("package", 0, -1).use { packageInSession ->
@ -55,6 +58,9 @@ class ExtensionInstallActivity : Activity() {
session.commit(statusReceiver) session.commit(statusReceiver)
val extensionManager: ExtensionManager by injectLazy() val extensionManager: ExtensionManager by injectLazy()
extensionManager.setInstalling(downloadId, sessionId) extensionManager.setInstalling(downloadId, sessionId)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
(getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager).remove(downloadId)
}
data.close() data.close()
} catch (error: Exception) { } catch (error: Exception) {
// Either install package can't be found (probably bots) or there's a security exception // Either install package can't be found (probably bots) or there's a security exception

View File

@ -5,20 +5,35 @@ import android.content.BroadcastReceiver
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.IntentFilter import android.content.IntentFilter
import android.content.pm.PackageInstaller
import android.net.Uri import android.net.Uri
import android.os.Environment import android.os.Environment
import androidx.core.net.toUri import androidx.core.net.toUri
import com.jakewharton.rxrelay.PublishRelay import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.extension.model.InstallStep import eu.kanade.tachiyomi.extension.model.InstallStep
import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo
import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.storage.getUriCompat
import rx.Observable import kotlinx.coroutines.CoroutineScope
import rx.android.schedulers.AndroidSchedulers 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 timber.log.Timber
import java.io.File import java.io.File
import java.util.concurrent.TimeUnit
/** /**
* The installer which installs, updates and uninstalls the extensions. * 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 * 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. * 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 * The currently requested downloads, with the package name (unique id) as key, and the id
* returned by the download manager. * returned by the download manager.
*/ */
private val activeDownloads = hashMapOf<String, Long>() val activeDownloads = hashMapOf<String, Long>()
/** /**
* 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<Pair<Long, InstallStep>>() val downloadsStateFlow = MutableStateFlow(0L to ExtensionIntallInfo(InstallStep.Pending, null))
/** Map of download id to installer session id */ /** Map of download id to installer session id */
val downloadInstallerMap = hashMapOf<Long, Int>() val downloadInstallerMap = hashMapOf<Long, Int>()
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. * step in the installation process.
* *
* @param url The url of the apk. * @param url The url of the apk.
* @param extension The extension to install. * @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<ExtensionIntallInfo> {
val pkgName = extension.pkgName val pkgName = extension.pkgName
val oldDownload = activeDownloads[pkgName] val oldDownload = activeDownloads[pkgName]
@ -79,77 +89,120 @@ internal class ExtensionInstaller(private val context: Context) {
val request = DownloadManager.Request(downloadUri) val request = DownloadManager.Request(downloadUri)
.setTitle(extension.name) .setTitle(extension.name)
.setMimeType(APK_MIME) .setMimeType(APK_MIME)
.setDestinationInExternalFilesDir(context, Environment.DIRECTORY_DOWNLOADS, downloadUri.lastPathSegment) .setDestinationInExternalFilesDir(
context,
Environment.DIRECTORY_DOWNLOADS,
downloadUri.lastPathSegment
)
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED) .setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
val id = downloadManager.enqueue(request) val id = downloadManager.enqueue(request)
activeDownloads[pkgName] = id activeDownloads[pkgName] = id
downloadsRelay.filter { it.first == id } scope.launch {
.map { flowOf(
val sessionId = downloadInstallerMap[it.first] ?: return@map it.second to null pollStatus(id),
val session = context.packageManager.packageInstaller.getSessionInfo(sessionId) pollInstallStatus(id)
it.second to session ).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. * manager doesn't have any notification system. It'll stop once the download finishes.
* *
* @param id The id of the download to poll. * @param id The id of the download to poll.
*/ */
private fun pollStatus(id: Long): Observable<ExtensionIntallInfo> { private fun pollStatus(id: Long): Flow<ExtensionIntallInfo> {
val query = DownloadManager.Query().setFilterById(id) val query = DownloadManager.Query().setFilterById(id)
return Observable.interval(0, 1, TimeUnit.SECONDS) return flow {
// Get the current download status while (true) {
.map { val newDownloadState = try {
downloadManager.query(query).use { cursor -> downloadManager.query(query)?.use { cursor ->
cursor.moveToFirst() cursor.moveToFirst()
cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)) cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
} }
} catch (_: Exception) {
}
if (newDownloadState != null) {
emit(newDownloadState)
}
delay(1000)
}
} }
// Ignore duplicate results
.distinctUntilChanged() .distinctUntilChanged()
// Stop polling when the download fails or finishes .transformWhile {
.takeUntil { it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED } emit(it)
// Map to our model !(it == DownloadManager.STATUS_SUCCESSFUL || it == DownloadManager.STATUS_FAILED)
.flatMap { status -> }
val step = when (status) { .flatMapConcat { downloadState ->
val step = when (downloadState) {
DownloadManager.STATUS_PENDING -> InstallStep.Pending DownloadManager.STATUS_PENDING -> InstallStep.Pending
DownloadManager.STATUS_RUNNING -> InstallStep.Downloading DownloadManager.STATUS_RUNNING -> InstallStep.Downloading
else -> return@flatMap Observable.empty() else -> return@flatMapConcat emptyFlow()
} }
Observable.just(ExtensionIntallInfo(step, null)) flowOf(ExtensionIntallInfo(step, null))
}
.doOnError {
Timber.e(it)
} }
} }
private fun pollInstallStatus(id: Long): Observable<ExtensionIntallInfo> { /**
return Observable.interval(0, 500, TimeUnit.MILLISECONDS) * Returns a flow that polls the given installer session for its status every half second, as the
.flatMap { * manager doesn't have any notification system. This will only stop once
val sessionId = downloadInstallerMap[id] ?: return@flatMap Observable.empty() *
val session = context.packageManager.packageInstaller.getSessionInfo(sessionId) * @param id The id of the download mapped to the session to poll.
Observable.just(InstallStep.Installing to session) */
private fun pollInstallStatus(id: Long): Flow<ExtensionIntallInfo> {
return flow {
while (true) {
val sessionId = downloadInstallerMap[id]
if (sessionId != null) {
val session =
context.packageManager.packageInstaller.getSessionInfo(sessionId)
emit(InstallStep.Installing to session)
} }
.doOnError { delay(500)
}
}
.takeWhile { info ->
val sessionId = downloadInstallerMap[id]
if (sessionId != null) {
info.second != null
} else {
true
}
}
.catch {
Timber.e(it) 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. * @param downloadId The id of the download.
*/ */
fun setInstalling(downloadId: Long, sessionId: Int) { fun setInstalling(downloadId: Long, sessionId: Int) {
downloadsRelay.call(downloadId to InstallStep.Installing) downloadsStateFlow.tryEmit(downloadId to ExtensionIntallInfo(InstallStep.Installing, null))
downloadInstallerMap[downloadId] = sessionId downloadInstallerMap[downloadId] = sessionId
} }
@ -204,7 +257,11 @@ internal class ExtensionInstaller(private val context: Context) {
fun setInstallationResult(downloadId: Long, result: Boolean) { fun setInstallationResult(downloadId: Long, result: Boolean) {
val step = if (result) InstallStep.Installed else InstallStep.Error val step = if (result) InstallStep.Installed else InstallStep.Error
downloadInstallerMap.remove(downloadId) 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 // Set next installation step
if (uri != null) { if (uri != null) {
downloadsRelay.call(id to InstallStep.Loading) downloadsStateFlow.tryEmit(id to ExtensionIntallInfo(InstallStep.Loading, null))
} else { } else {
Timber.e("Couldn't locate downloaded APK") Timber.e("Couldn't locate downloaded APK")
downloadsRelay.call(id to InstallStep.Error) downloadsStateFlow.tryEmit(id to ExtensionIntallInfo(InstallStep.Error, null))
return return
} }

View File

@ -24,5 +24,6 @@ class ExtensionAdapter(val listener: OnButtonClickListener) :
interface OnButtonClickListener { interface OnButtonClickListener {
fun onButtonClick(position: Int) fun onButtonClick(position: Int)
fun onCancelClick(position: Int) fun onCancelClick(position: Int)
fun onUpdateAllClicked(position: Int)
} }
} }

View File

@ -19,12 +19,15 @@ import eu.kanade.tachiyomi.ui.migration.SelectionHeader
import eu.kanade.tachiyomi.ui.migration.SourceItem import eu.kanade.tachiyomi.ui.migration.SourceItem
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.executeOnIO import eu.kanade.tachiyomi.util.system.executeOnIO
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll 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.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import rx.Observable
import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
@ -38,7 +41,7 @@ typealias ExtensionIntallInfo = Pair<InstallStep, PackageInstaller.SessionInfo?>
class ExtensionBottomPresenter( class ExtensionBottomPresenter(
private val bottomSheet: ExtensionBottomSheet, private val bottomSheet: ExtensionBottomSheet,
private val extensionManager: ExtensionManager = Injekt.get(), private val extensionManager: ExtensionManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get() val preferences: PreferencesHelper = Injekt.get()
) : BaseCoroutinePresenter(), ExtensionsChangedListener { ) : BaseCoroutinePresenter(), ExtensionsChangedListener {
private var extensions = emptyList<ExtensionItem>() private var extensions = emptyList<ExtensionItem>()
@ -76,7 +79,10 @@ class ExtensionBottomPresenter(
sourceItems = findSourcesWithManga(favs) sourceItems = findSourcesWithManga(favs)
mangaItems = HashMap( mangaItems = HashMap(
sourceItems.associate { 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) { withContext(Dispatchers.Main) {
@ -89,6 +95,27 @@ class ExtensionBottomPresenter(
} }
listOf(migrationJob, extensionJob).awaitAll() 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<Manga>): List<SourceItem> { private fun findSourcesWithManga(library: List<Manga>): List<SourceItem> {
@ -176,7 +203,8 @@ class ExtensionBottomPresenter(
updatesSorted.size, updatesSorted.size,
updatesSorted.size updatesSorted.size
), ),
updatesSorted.size updatesSorted.size,
items.count { it.extension.pkgName in currentDownloads.keys } != updatesSorted.size
) )
items += updatesSorted.map { extension -> items += updatesSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName]) ExtensionItem(extension, header, currentDownloads[extension.pkgName])
@ -215,7 +243,7 @@ class ExtensionBottomPresenter(
@Synchronized @Synchronized
private fun updateInstallStep( private fun updateInstallStep(
extension: Extension, extension: Extension,
state: InstallStep, state: InstallStep?,
session: PackageInstaller.SessionInfo? session: PackageInstaller.SessionInfo?
): ExtensionItem? { ): ExtensionItem? {
val extensions = extensions.toMutableList() val extensions = extensions.toMutableList()
@ -242,13 +270,21 @@ class ExtensionBottomPresenter(
fun installExtension(extension: Extension.Available) { fun installExtension(extension: Extension.Available) {
if (isNotMIUIOptimized()) { if (isNotMIUIOptimized()) {
extensionManager.installExtension(extension).subscribeToInstallUpdate(extension) presenterScope.launch {
extensionManager.installExtension(ExtensionManager.ExtensionInfo(extension), presenterScope)
.launchIn(this)
}
} }
} }
fun updateExtension(extension: Extension.Installed) { fun updateExtension(extension: Extension.Installed) {
if (isNotMIUIOptimized()) { 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 return true
} }
private fun Observable<ExtensionIntallInfo>.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) { fun uninstallExtension(pkgName: String) {
extensionManager.uninstallExtension(pkgName) extensionManager.uninstallExtension(pkgName)
} }

View File

@ -7,6 +7,7 @@ import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import android.widget.LinearLayout import android.widget.LinearLayout
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.afollestad.materialdialogs.MaterialDialog
import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
@ -201,6 +202,35 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At
presenter.cancelExtensionInstall(extension) 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 { override fun onItemClick(view: View?, position: Int): Boolean {
when (binding.tabs.selectedTabPosition) { when (binding.tabs.selectedTabPosition) {
0 -> { 0 -> {
@ -298,6 +328,7 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At
extAdapter?.updateDataSet(extensions) extAdapter?.updateDataSet(extensions)
} }
updateExtTitle() updateExtTitle()
updateExtUpdateAllButton()
} }
fun canGoBack(): Boolean { fun canGoBack(): Boolean {
@ -310,6 +341,20 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At
fun downloadUpdate(item: ExtensionItem) { fun downloadUpdate(item: ExtensionItem) {
extAdapter?.updateItem(item, item.installStep) 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) { override fun trustSignature(signatureHash: String) {

View File

@ -1,7 +1,9 @@
package eu.kanade.tachiyomi.ui.extension package eu.kanade.tachiyomi.ui.extension
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.os.Build
import android.view.View import android.view.View
import androidx.core.view.isVisible
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import eu.davidea.flexibleadapter.FlexibleAdapter import eu.davidea.flexibleadapter.FlexibleAdapter
import eu.davidea.flexibleadapter.items.IFlexible import eu.davidea.flexibleadapter.items.IFlexible
@ -13,8 +15,16 @@ class ExtensionGroupHolder(view: View, adapter: FlexibleAdapter<IFlexible<Recycl
private val binding = ExtensionCardHeaderBinding.bind(view) private val binding = ExtensionCardHeaderBinding.bind(view)
init {
binding.extButton.setOnClickListener {
(adapter as? ExtensionAdapter)?.listener?.onUpdateAllClicked(bindingAdapterPosition)
}
}
@SuppressLint("SetTextI18n") @SuppressLint("SetTextI18n")
fun bind(item: ExtensionGroupItem) { fun bind(item: ExtensionGroupItem) {
binding.title.text = item.name binding.title.text = item.name
binding.extButton.isVisible = item.canUpdate != null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
binding.extButton.isEnabled = item.canUpdate == true
} }
} }

View File

@ -13,7 +13,7 @@ import eu.kanade.tachiyomi.R
* @param name The header name. * @param name The header name.
* @param size The number of items in the group. * @param size The number of items in the group.
*/ */
data class ExtensionGroupItem(val name: String, val size: Int) : AbstractHeaderItem<ExtensionGroupHolder>() { data class ExtensionGroupItem(val name: String, val size: Int, var canUpdate: Boolean? = null) : AbstractHeaderItem<ExtensionGroupHolder>() {
/** /**
* Returns the layout resource of this item. * Returns the layout resource of this item.

View File

@ -68,6 +68,7 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
@Suppress("ResourceType") @Suppress("ResourceType")
fun bindButton(item: ExtensionItem) = with(binding.extButton) { fun bindButton(item: ExtensionItem) = with(binding.extButton) {
if (item.installStep == InstallStep.Done) return@with
isEnabled = true isEnabled = true
isClickable = true isClickable = true
isActivated = false isActivated = false
@ -87,6 +88,7 @@ class ExtensionHolder(view: View, val adapter: ExtensionAdapter) :
InstallStep.Installing -> R.string.installing InstallStep.Installing -> R.string.installing
InstallStep.Installed -> R.string.installed InstallStep.Installed -> R.string.installed
InstallStep.Error -> R.string.retry InstallStep.Error -> R.string.retry
else -> return@with
} }
) )
if (installStep != InstallStep.Error) { if (installStep != InstallStep.Error) {

View File

@ -3,10 +3,12 @@ package eu.kanade.tachiyomi.ui.main
import android.animation.AnimatorSet import android.animation.AnimatorSet
import android.animation.ValueAnimator import android.animation.ValueAnimator
import android.app.Dialog import android.app.Dialog
import android.app.assist.AssistContent
import android.content.Intent import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.Rect import android.graphics.Rect
import android.graphics.drawable.Drawable import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler 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.data.updater.UpdaterNotifier
import eu.kanade.tachiyomi.databinding.MainActivityBinding import eu.kanade.tachiyomi.databinding.MainActivityBinding
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi 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.MaterialMenuSheet
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.base.controller.BaseController import eu.kanade.tachiyomi.ui.base.controller.BaseController
@ -647,6 +650,25 @@ open class MainActivity : BaseActivity<MainActivityBinding>(), DownloadServiceLi
return true 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() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
overflowDialog?.dismiss() overflowDialog?.dismiss()

View File

@ -4,6 +4,9 @@ import android.annotation.SuppressLint
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.view.LayoutInflater import android.view.LayoutInflater
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintLayout
@ -124,6 +127,15 @@ class MangaHeaderHolder(
) )
true 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) } mangaCover.setOnClickListener { adapter.delegate.zoomImageFromThumb(coverCard) }
trackButton.setOnClickListener { adapter.delegate.showTrackingSheet() } trackButton.setOnClickListener { adapter.delegate.showTrackingSheet() }
if (startExpanded) expandDesc() if (startExpanded) expandDesc()

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader package eu.kanade.tachiyomi.ui.reader
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.ClipData import android.content.ClipData
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
@ -9,6 +10,7 @@ import android.content.res.Configuration
import android.graphics.Bitmap import android.graphics.Bitmap
import android.graphics.Color import android.graphics.Color
import android.graphics.drawable.LayerDrawable import android.graphics.drawable.LayerDrawable
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent 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.data.preference.toggle
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
import eu.kanade.tachiyomi.source.model.Page 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.MaterialMenuSheet
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.ui.main.MainActivity import eu.kanade.tachiyomi.ui.main.MainActivity
@ -1225,6 +1228,18 @@ class ReaderActivity :
startActivity(Intent.createChooser(intent, getString(R.string.share))) 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 * Called from the page sheet. It delegates saving the image of the given [page] on external
* storage to the presenter. * storage to the presenter.

View File

@ -20,6 +20,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.track.DelayedTrackingUpdateJob import eu.kanade.tachiyomi.data.track.DelayedTrackingUpdateJob
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.source.LocalSource import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
@ -76,6 +77,9 @@ class ReaderPresenter(
var manga: Manga? = null var manga: Manga? = null
private set 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. * The chapter id of the currently loaded chapter. Used to restore from process kill.
*/ */

View File

@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.ui.setting
import android.app.Dialog import android.app.Dialog
import android.content.Intent import android.content.Intent
import android.os.Build
import android.os.Bundle import android.os.Bundle
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.preference.PreferenceScreen import androidx.preference.PreferenceScreen
@ -190,15 +191,16 @@ class AboutController : SettingsController() {
) )
override fun onCreateDialog(savedViewState: Bundle?): Dialog { override fun onCreateDialog(savedViewState: Bundle?): Dialog {
val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
return MaterialDialog(activity!!) return MaterialDialog(activity!!)
.title(R.string.new_version_available) .title(R.string.new_version_available)
.message(text = args.getString(BODY_KEY) ?: "") .message(text = args.getString(BODY_KEY) ?: "")
.positiveButton(R.string.download) { .positiveButton(if (isOnA12) R.string.update else R.string.download) {
val appContext = applicationContext val appContext = applicationContext
if (appContext != null) { if (appContext != null) {
// Start download // Start download
val url = args.getString(URL_KEY) ?: "" val url = args.getString(URL_KEY) ?: ""
UpdaterService.start(appContext, url) UpdaterService.start(appContext, url, true)
} }
} }
.negativeButton(R.string.ignore) .negativeButton(R.string.ignore)

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.setting package eu.kanade.tachiyomi.ui.setting
import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDelegate
@ -8,6 +9,7 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.asImmediateFlow import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn 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.ui.main.MainActivity
import eu.kanade.tachiyomi.util.system.LocaleHelper import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.appDelegateNightMode import eu.kanade.tachiyomi.util.system.appDelegateNightMode
@ -270,6 +272,20 @@ class SettingsGeneralController : SettingsController() {
defaultValue = "" 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) { override fun onDestroyView(view: View) {

View File

@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context import android.content.Context
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.os.Build
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import androidx.appcompat.app.AppCompatDelegate 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.ThemeUtil
import eu.kanade.tachiyomi.util.system.Themes import eu.kanade.tachiyomi.util.system.Themes
import eu.kanade.tachiyomi.util.system.appDelegateNightMode 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.dpToPx
import eu.kanade.tachiyomi.util.system.isInNightMode import eu.kanade.tachiyomi.util.system.isInNightMode
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
@ -56,14 +58,15 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu
selectExtensionLight = fastAdapterLight.getSelectExtension().setThemeListener(false) selectExtensionLight = fastAdapterLight.getSelectExtension().setThemeListener(false)
selectExtensionDark = fastAdapterDark.getSelectExtension().setThemeListener(true) selectExtensionDark = fastAdapterDark.getSelectExtension().setThemeListener(true)
val enumConstants = Themes.values() val enumConstants = Themes.values()
val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
itemAdapterLight.set( itemAdapterLight.set(
enumConstants enumConstants
.filter { !it.isDarkTheme || it.followsSystem } .filter { (!it.isDarkTheme || it.followsSystem) && (it.styleRes != R.style.Theme_Tachiyomi_Monet || isOnA12) }
.map { ThemeItem(it, false) } .map { ThemeItem(it, false) }
) )
itemAdapterDark.set( itemAdapterDark.set(
enumConstants enumConstants
.filter { it.isDarkTheme || it.followsSystem } .filter { (it.isDarkTheme || it.followsSystem) && (it.styleRes != R.style.Theme_Tachiyomi_Monet || isOnA12) }
.map { ThemeItem(it, true) } .map { ThemeItem(it, true) }
) )
isSelectable = false isSelectable = false
@ -207,6 +210,7 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu
inner class ViewHolder(view: View) : FastAdapter.ViewHolder<ThemeItem>(view) { inner class ViewHolder(view: View) : FastAdapter.ViewHolder<ThemeItem>(view) {
val binding = ThemeItemBinding.bind(view) val binding = ThemeItemBinding.bind(view)
override fun bindView(item: ThemeItem, payloads: List<Any>) { override fun bindView(item: ThemeItem, payloads: List<Any>) {
binding.themeNameText.setText( binding.themeNameText.setText(
if (item.isDarkTheme) { if (item.isDarkTheme) {
@ -228,6 +232,53 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu
binding.themeSelected.alpha = if (themeMatchesApp) 1f else 0.5f binding.themeSelected.alpha = if (themeMatchesApp) 1f else 0.5f
binding.checkbox.alpha = if (themeMatchesApp) 1f else 0.5f binding.checkbox.alpha = if (themeMatchesApp) 1f else 0.5f
} }
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.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.themeToolbar.setBackgroundColor(item.colors.appBar)
binding.themeAppBarText.imageTintList = binding.themeAppBarText.imageTintList =
ColorStateList.valueOf(item.colors.appBarText) ColorStateList.valueOf(item.colors.appBarText)
@ -249,6 +300,7 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu
binding.themeItem3.imageTintList = binding.themeItem3.imageTintList =
ColorStateList.valueOf(item.colors.inactiveTab) ColorStateList.valueOf(item.colors.inactiveTab)
binding.themeLayout.setBackgroundColor(item.colors.colorBackground) binding.themeLayout.setBackgroundColor(item.colors.colorBackground)
}
if (item.isDarkTheme && preferences.themeDarkAmoled().get()) { if (item.isDarkTheme && preferences.themeDarkAmoled().get()) {
binding.themeLayout.setBackgroundColor(Color.BLACK) binding.themeLayout.setBackgroundColor(Color.BLACK)
if (!ThemeUtil.isColoredTheme(item.theme)) { if (!ThemeUtil.isColoredTheme(item.theme)) {

View File

@ -11,6 +11,12 @@ import kotlin.math.roundToInt
@Suppress("unused") @Suppress("unused")
enum class Themes(@StyleRes val styleRes: Int, val nightMode: Int, @StringRes val nameRes: Int, @StringRes altNameRes: Int? = null) { 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( DEFAULT(
R.style.Theme_Tachiyomi, R.style.Theme_Tachiyomi,
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM, AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,

View File

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<selector xmlns:android="http://schemas.android.com/apk/res/android">
<item android:alpha="1.00" android:color="?colorAccent" android:state_checkable="true" android:state_checked="true" android:state_enabled="true"/>
<item android:alpha="0.60" android:color="?colorOnSurface" android:state_checkable="true" android:state_checked="false" android:state_enabled="true"/>
<item android:alpha="1.00" android:color="?colorAccent" android:state_enabled="true"/>
<item android:alpha="0.38" android:color="?colorOnSurface"/>
</selector>

View File

@ -0,0 +1,171 @@
<animated-vector
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:aapt="http://schemas.android.com/aapt">
<aapt:attr name="android:drawable">
<vector
android:name="vector"
android:width="256dp"
android:height="256dp"
android:viewportWidth="256"
android:viewportHeight="256">
<group
android:name="main_group"
android:pivotX="128"
android:pivotY="128"
android:scaleX="0.5"
android:scaleY="0.5">
<group
android:name="group"
android:translateY="32">
<path
android:name="crown"
android:pathData="M 159.8 21.4 C 155.9 21.4 152.7 24.6 152.7 28.5 L 152.7 28.9 C 149.2 29.2 145 30.4 141.5 34 C 141.5 34 134.2 27.2 130.1 18.4 C 132.7 17.3 134.5 14.8 134.5 11.9 C 134.5 8 131.3 4.8 127.4 4.8 C 123.5 4.8 120.3 8 120.3 11.9 C 120.3 14.9 122.1 17.4 124.8 18.5 C 120.8 27.2 113.5 34 113.5 34 C 110.3 30.8 106.6 29.5 103.4 29.1 L 103.4 28.6 C 103.4 24.7 100.2 21.5 96.3 21.5 C 92.4 21.5 89.2 24.7 89.2 28.6 C 89.2 32.5 92.4 35.7 96.3 35.7 C 97.1 35.7 97.8 35.6 98.5 35.4 C 104 46.8 105.7 59.9 105.7 59.9 L 149.4 59.9 C 149.4 59.9 151.1 46.5 156.7 35 C 157.6 35.5 158.7 35.7 159.8 35.7 C 163.7 35.7 166.9 32.5 166.9 28.6 C 166.9 24.7 163.7 21.4 159.8 21.4 Z"
android:fillColor="@color/splashIcon"
android:fillAlpha="0"/>
</group>
<path
android:name="path"
android:pathData="M 231.052 55.693 C 231.059 55.696 231.065 55.699 231.071 55.702 C 231.156 55.742 231.233 55.812 231.303 55.922 C 231.945 56.931 232 61.242 232 75.2 L 232 95.3 L 232 95.3 C 232 95.3 232 95.3 232 95.3 C 232 95.3 232 95.3 232 95.3 L 232 95.3 L 232 95.3 C 232 95.3 229.5 56.3 230.4 55.8 C 230.4 55.8 230.4 55.8 230.4 55.8 C 230.591 55.711 230.759 55.654 230.907 55.662 C 230.932 55.663 230.956 55.666 230.98 55.671 C 231.005 55.676 231.029 55.684 231.052 55.693"
android:fillColor="@color/splashIcon"
android:fillAlpha="0"/>
<path
android:name="path_1"
android:pathData="M 165.7 106.2 C 162.6 126.1 150.9 164.7 138.4 196.4 L 131.8 212.8 L 129.1 239.7 L 157.2 236.8 C 157.2 236.8 173.3 211.9 173.3 211.6 C 173.6 211 175.9 206 178.3 200.5 C 180.7 195 185.4 183.1 188.7 174 C 195.1 156.5 209 112.9 209 110.4 C 209 109.2 204.6 107.7 188.9 103.8 C 177.8 101 168.2 98.9 167.6 98.9 C 167.2 98.9 166.3 102.2 165.7 106.2 Z"
android:fillColor="@color/splashIcon"
android:fillAlpha="0"/>
<path
android:name="path_5"
android:pathData="M 62.8 105.7 C 52.9 109.4 44.9 113 44.9 113.6 C 44.9 114.3 46.2 117.7 47.8 121.3 C 55.4 138.1 67.4 179.7 70.7 200.7 C 71.3 204.8 72.2 208.1 72.8 208.1 C 73.4 208.1 82.7 205.1 93.6 201.4 C 107.7 196.5 113.3 194.2 113.3 193.1 C 113.3 190.1 96.4 135.2 88.4 112.4 C 84.8 102.3 83.3 98.9 82 99 C 81.2 99 72.6 102 62.8 105.7 Z"
android:fillColor="@color/splashIcon"
android:fillAlpha="0"/>
<path
android:name="path_6"
android:pathData="M 12.6 212.8 L 12.6 212.8 L 12.6 250.8 L 12.6 250.8 L 12.6 213.609 L 12.6 212.8"
android:fillColor="@color/splashIcon"
android:fillAlpha="0"/>
<path
android:name="path_3"
android:pathData="M 126.6 93.2 C 198.2 93.2 223.5 93.9 229.9 95.7 L 232 96.3 L 232 76.2 C 232 57.4 231.9 56.1 230.4 56.8 C 229.5 57.3 210.5 57.7 188.2 57.9 L 149.7 58.2 L 149 34.9 L 106.1 34.9 L 105.2 58.3 L 65 57.9 C 42.8 57.7 23.8 57.2 22.9 56.8 C 21.3 56 21.2 57.4 21.2 76.2 L 21.2 96.3 L 23.4 95.7 C 29.7 93.9 55 93.2 126.6 93.2 Z M 82 100 C 81.2 100 72.6 103 62.8 106.7 C 52.9 110.4 44.9 114 44.9 114.6 C 44.9 115.3 46.2 118.7 47.8 122.3 C 55.4 139.1 67.4 180.7 70.7 201.7 C 71.3 205.8 72.2 209.1 72.8 209.1 C 73.4 209.1 82.7 206.1 93.6 202.4 C 107.7 197.5 113.3 195.2 113.3 194.1 C 113.3 191.1 96.4 136.2 88.4 113.4 C 84.8 103.3 83.3 99.9 82 100 Z"
android:fillColor="@color/splashIcon"
android:fillAlpha="0.25"/>
<path
android:name="path_4"
android:pathData="M 172.6 213.8 C 173.1 213.1 173.3 212.6 173.3 212.6 C 173.6 212 175.9 207 178.3 201.5 C 180.7 196 185.4 184.1 188.7 175 C 195.1 157.5 209 113.9 209 111.4 C 209 110.2 204.6 108.7 188.9 104.8 C 177.8 102 168.2 99.9 167.6 99.9 C 167.2 99.9 166.3 103.2 165.7 107.2 C 162.6 127.1 150.9 165.7 138.4 197.4 L 131.8 213.8 L 12.6 213.8 L 12.6 251.8 L 243.4 251.8 L 243.4 213.8 L 172.6 213.8 Z"
android:fillColor="@color/splashIcon"
android:fillAlpha="0.25"/>
</group>
</vector>
</aapt:attr>
<target android:name="path_1">
<aapt:attr name="android:animation">
<set>
<objectAnimator
android:propertyName="pathData"
android:startOffset="425"
android:duration="250"
android:valueFrom="M 131.2 214.4 C 131.185 214.554 131.169 214.708 131.154 214.863 L 128.5 241.3 L 156.7 238.4 L 172.8 213.2 C 172.8 213.2 172.8 213.2 172.8 213.2 C 172.8 213.2 172.8 213.2 172.8 213.2 C 172.8 213.2 172.8 213.2 172.8 213.2 C 172.8 213.2 172.8 213.2 172.8 213.2 C 165.68 213.405 158.56 213.611 151.44 213.816 C 144.916 214.004 138.393 214.193 131.869 214.381 C 131.646 214.387 131.423 214.394 131.2 214.4 L 131.2 214.4"
android:valueTo="M 165.7 106.2 C 162.6 126.1 150.9 164.7 138.4 196.4 L 131.8 212.8 L 129.1 239.7 L 157.2 236.8 C 157.2 236.8 173.3 211.9 173.3 211.6 C 173.6 211 175.9 206 178.3 200.5 C 180.7 195 185.4 183.1 188.7 174 C 195.1 156.5 209 112.9 209 110.4 C 209 109.2 204.6 107.7 188.9 103.8 C 177.8 101 168.2 98.9 167.6 98.9 C 167.2 98.9 166.3 102.2 165.7 106.2 L 165.7 106.2"
android:valueType="pathType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
<objectAnimator
android:propertyName="fillAlpha"
android:startOffset="450"
android:duration="1"
android:valueFrom="0"
android:valueTo="1"
android:valueType="floatType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</set>
</aapt:attr>
</target>
<target android:name="path_6">
<aapt:attr name="android:animation">
<set>
<objectAnimator
android:propertyName="pathData"
android:startOffset="250"
android:duration="250"
android:valueFrom="M 12.6 212.8 L 12.6 212.8 L 12.6 250.8 L 12.6 250.8 L 12.6 213.609 L 12.6 212.8"
android:valueTo="M 72.3 212.8 L 12.6 212.8 L 12.6 250.8 L 243.4 250.8 L 243.4 212.8 L 72.3 212.8"
android:valueType="pathType"
android:interpolator="@android:anim/accelerate_interpolator"/>
<objectAnimator
android:propertyName="fillAlpha"
android:startOffset="250"
android:duration="1"
android:valueFrom="0"
android:valueTo="1"
android:valueType="floatType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</set>
</aapt:attr>
</target>
<target android:name="path">
<aapt:attr name="android:animation">
<set>
<objectAnimator
android:propertyName="pathData"
android:startOffset="250"
android:duration="250"
android:valueFrom="M 231.052 55.693 C 231.059 55.696 231.065 55.699 231.071 55.702 C 231.156 55.742 231.233 55.812 231.303 55.922 C 231.945 56.931 232 61.242 232 75.2 L 232 95.3 L 232 95.3 C 232 95.3 232 95.3 232 95.3 C 232 95.3 232 95.3 232 95.3 L 232 95.3 L 232 95.3 C 232 95.3 229.5 56.3 230.4 55.8 C 230.4 55.8 230.4 55.8 230.4 55.8 C 230.591 55.711 230.759 55.654 230.907 55.662 C 230.932 55.663 230.956 55.666 230.98 55.671 C 231.005 55.676 231.029 55.684 231.052 55.693"
android:valueTo="M 105.6 57.3 C 92.067 57.167 78.533 57.033 65 56.9 C 42.8 56.7 23.8 56.2 22.9 55.8 C 21.3 55 21.2 56.4 21.2 75.2 L 21.2 95.3 L 23.4 94.7 C 29.7 92.9 55 92.2 126.6 92.2 C 198.2 92.2 223.5 92.9 229.9 94.7 L 232 95.3 L 232 75.2 C 232 56.4 231.9 55.1 230.4 55.8 C 229.5 56.3 210.5 56.7 188.2 56.9 C 174.7 57 161.2 57.1 147.7 57.2 C 133.667 57.233 119.633 57.267 105.6 57.3 C 105.6 57.3 105.6 57.3 105.6 57.3"
android:valueType="pathType"
android:interpolator="@android:anim/accelerate_interpolator"/>
<objectAnimator
android:propertyName="fillAlpha"
android:startOffset="250"
android:duration="1"
android:valueFrom="0"
android:valueTo="1"
android:valueType="floatType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</set>
</aapt:attr>
</target>
<target android:name="path_5">
<aapt:attr name="android:animation">
<set>
<objectAnimator
android:propertyName="pathData"
android:startOffset="450"
android:duration="225"
android:valueFrom="M 62.8 105.7 C 52.9 109.4 44.9 113 44.9 113.6 C 44.9 113.6 44.9 113.6 44.9 113.6 C 44.9 113.6 44.9 113.6 44.9 113.6 C 44.9 113.6 44.9 113.6 44.9 113.6 C 44.9 113.624 44.945 113.629 45.033 113.616 C 47.46 113.265 82.363 99.262 82.03 99.003 C 82.026 99 82.016 98.999 82 99 C 82 99 82 99 82 99 C 81.2 99 72.6 102 62.8 105.7 Z"
android:valueTo="M 62.8 105.7 C 52.9 109.4 44.9 113 44.9 113.6 C 44.9 114.3 46.2 117.7 47.8 121.3 C 55.4 138.1 67.4 179.7 70.7 200.7 C 71.3 204.8 72.2 208.1 72.8 208.1 C 73.4 208.1 82.7 205.1 93.6 201.4 C 107.7 196.5 113.3 194.2 113.3 193.1 C 113.3 190.1 96.4 135.2 88.4 112.4 C 84.8 102.3 83.3 98.9 82 99 C 81.2 99 72.6 102 62.8 105.7 Z"
android:valueType="pathType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
<objectAnimator
android:propertyName="fillAlpha"
android:startOffset="450"
android:duration="1"
android:valueFrom="0"
android:valueTo="1"
android:valueType="floatType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</set>
</aapt:attr>
</target>
<target android:name="group">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="translateY"
android:startOffset="550"
android:duration="225"
android:valueFrom="32"
android:valueTo="0"
android:valueType="floatType"
android:interpolator="@android:interpolator/linear_out_slow_in"/>
</aapt:attr>
</target>
<target android:name="crown">
<aapt:attr name="android:animation">
<objectAnimator
android:propertyName="fillAlpha"
android:startOffset="550"
android:duration="100"
android:valueFrom="0"
android:valueTo="1"
android:valueType="floatType"
android:interpolator="@android:interpolator/fast_out_slow_in"/>
</aapt:attr>
</target>
</animated-vector>

View File

@ -24,6 +24,25 @@
android:layout_marginTop="20dp" android:layout_marginTop="20dp"
tools:text="Title"/> tools:text="Title"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/ext_button"
style="@style/Widget.MaterialComponents.Button.TextButton"
android:layout_width="wrap_content"
android:layout_height="40dp"
android:layout_marginEnd="16dp"
android:textAllCaps="false"
android:textColor="@color/accent_text_btn_color_selector"
android:visibility="gone"
tools:visibility="visible"
app:layout_constraintBaseline_toBaselineOf="@id/title"
app:layout_constraintBottom_toBottomOf="@id/title"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toEndOf="@id/title"
app:layout_constraintTop_toTopOf="@id/title"
app:rippleColor="@color/fullRippleColor"
android:text="@string/update_all" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</FrameLayout> </FrameLayout>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tachiyomi.Monet">
<item name="colorPrimary">@android:color/system_accent2_800</item>
<item name="colorAccent">@android:color/system_accent1_300</item>
<item name="colorAccentText">@android:color/system_accent1_200</item>
<item name="colorPrimaryVariant">@android:color/system_neutral1_800</item>
<item name="colorSecondary">@android:color/system_neutral1_900</item>
<item name="background">@android:color/system_neutral1_900</item>
<item name="android:textColorPrimary">@android:color/system_accent2_10</item>
<item name="android:colorBackground">@android:color/system_neutral1_900</item>
<item name="actionBarTintColor">@android:color/system_accent2_10</item>
<item name="colorOnAccent">@android:color/system_neutral2_900</item>
</style>
</resources>

View File

@ -18,7 +18,7 @@
<color name="divider">@color/md_white_1000_12</color> <color name="divider">@color/md_white_1000_12</color>
<color name="download">@color/material_green_700</color> <color name="download">@color/material_green_700</color>
<color name="holo_red">#cc4444</color> <color name="holo_red">#cc4444</color>
<color name="splashIcon">@color/md_white_1000</color>
<color name="background">#1C1C1D</color> <color name="background">#1C1C1D</color>
<color name="dialog">#212121</color> <color name="dialog">#212121</color>

View File

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.Tachiyomi.Monet">
<item name="colorPrimary">@android:color/system_accent2_100</item>
<item name="colorAccent">@android:color/system_accent1_500</item>
<item name="colorAccentText">@android:color/system_accent1_800</item>
<item name="colorPrimaryVariant">@android:color/system_accent2_100</item>
<item name="colorSecondary">@android:color/system_neutral1_50</item>
<item name="background">@android:color/system_neutral1_50</item>
<item name="android:textColorPrimary">@android:color/system_neutral1_900</item>
<item name="android:colorBackground">@android:color/system_neutral1_50</item>
<item name="actionBarTintColor">@android:color/system_accent2_800</item>
</style>
</resources>

View File

@ -34,6 +34,7 @@
<color name="background">@color/md_grey_50</color> <color name="background">@color/md_grey_50</color>
<color name="dialog">@color/md_white_1000</color> <color name="dialog">@color/md_white_1000</color>
<color name="splashIcon">@color/md_black_1000</color>
<!-- Text Colors --> <!-- Text Colors -->
<color name="md_black_1000_87">#DE000000</color> <color name="md_black_1000_87">#DE000000</color>

View File

@ -117,6 +117,8 @@
<string name="no_new_updates_available">No new updates available</string> <string name="no_new_updates_available">No new updates available</string>
<string name="searching_for_updates">Searching for updates…</string> <string name="searching_for_updates">Searching for updates…</string>
<string name="release_page">Release page</string> <string name="release_page">Release page</string>
<string name="could_not_install_update">Could not install update</string>
<string name="update_completed">Update completed</string>
<!-- Main Screens --> <!-- Main Screens -->
@ -312,6 +314,9 @@
<string name="app_info">App info</string> <string name="app_info">App info</string>
<string name="_must_be_enabled_first">%1$s must be enabled first</string> <string name="_must_be_enabled_first">%1$s must be enabled first</string>
<string name="could_not_install_extension">Could not install extension</string> <string name="could_not_install_extension">Could not install extension</string>
<string name="update_all">Update all</string>
<string name="some_extensions_may_prompt">Some extensions may still prompt to be installed first.</string>
<string name="updating_extensions">Updating extensions</string>
<plurals name="_updates_pending"> <plurals name="_updates_pending">
<item quantity="one">%d update pending</item> <item quantity="one">%d update pending</item>
<item quantity="other">%d updates pending</item> <item quantity="other">%d updates pending</item>
@ -625,6 +630,8 @@
<string name="midnight_dusk">Midnight Dusk</string> <string name="midnight_dusk">Midnight Dusk</string>
<string name="spring_blossom">Spring Blossom</string> <string name="spring_blossom">Spring Blossom</string>
<string name="strawberry_daiquiri">Strawberry Daiquiri</string> <string name="strawberry_daiquiri">Strawberry Daiquiri</string>
<string name="a_brighter_you">A Brighter You</string>
<string name="a_calmer_you">A Calmer You</string>
<string name="yotsuba">Yotsuba</string> <string name="yotsuba">Yotsuba</string>
<string name="yin">Yin</string> <string name="yin">Yin</string>
<string name="yang">Yang</string> <string name="yang">Yang</string>
@ -651,6 +658,11 @@
<string name="starting_screen">Starting screen</string> <string name="starting_screen">Starting screen</string>
<string name="back_to_start">Back to start</string> <string name="back_to_start">Back to start</string>
<string name="pressing_back_to_start">Pressing back to starting screen</string> <string name="pressing_back_to_start">Pressing back to starting screen</string>
<string name="auto_updates">Auto-updates</string>
<string name="auto_update_app">Auto-update app</string>
<string name="over_wifi_only">Over Wi-Fi only</string>
<string name="over_any_network">Over any network</string>
<string name="dont_auto_update">Don\'t auto-update</string>
<string name="app_shortcuts">App shortcuts</string> <string name="app_shortcuts">App shortcuts</string>
<string name="show_recent_sources">Show recently used sources</string> <string name="show_recent_sources">Show recently used sources</string>
@ -980,5 +992,6 @@
<string name="use_default">Use default</string> <string name="use_default">Use default</string>
<string name="view_all_errors">View all errors</string> <string name="view_all_errors">View all errors</string>
<string name="view_chapters">View chapters</string> <string name="view_chapters">View chapters</string>
<string name="warning">Warning</string>
<string name="wifi">Wi-Fi</string> <string name="wifi">Wi-Fi</string>
</resources> </resources>

View File

@ -10,6 +10,9 @@
<item name="android:forceDarkAllowed" tools:targetApi="29">false</item> <item name="android:forceDarkAllowed" tools:targetApi="29">false</item>
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item> <item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</item>
<item name="android:windowDrawsSystemBarBackgrounds">true</item> <item name="android:windowDrawsSystemBarBackgrounds">true</item>
<item name="android:windowSplashScreenBackground" tools:targetApi="31">@color/colorPrimary</item>
<item name="android:windowSplashScreenAnimatedIcon" tools:targetApi="31">@drawable/anim_tachij2k_splash</item>
<item name="android:windowSplashScreenAnimationDuration" tools:targetApi="31">775</item>
<item name="colorPrimary">@color/app_color_primary</item> <item name="colorPrimary">@color/app_color_primary</item>
<item name="colorPrimaryVariant">@color/colorPrimary</item> <item name="colorPrimaryVariant">@color/colorPrimary</item>
<item name="colorSecondary">@color/background</item> <item name="colorSecondary">@color/background</item>
@ -105,6 +108,7 @@
<item name="colorAccentText">@color/colorAccentYinyangText</item> <item name="colorAccentText">@color/colorAccentYinyangText</item>
<item name="colorOnAccent">@color/colorOnAccentYinyang</item> <item name="colorOnAccent">@color/colorOnAccentYinyang</item>
</style> </style>
<style name="Theme.Tachiyomi.Monet"/>
<!--===============--> <!--===============-->
<!-- Launch Screen --> <!-- Launch Screen -->

View File

@ -93,7 +93,7 @@ object LegacyPluginClassPath {
} }
object AndroidVersions { object AndroidVersions {
const val compileSdk = 30 const val compileSdk = 31
const val minSdk = 23 const val minSdk = 23
const val targetSdk = 30 const val targetSdk = 30
const val versionCode = 77 const val versionCode = 77