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

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

View File

@ -20,6 +20,8 @@ object Notifications {
const val ID_UPDATER = 1
const val ID_DOWNLOAD_IMAGE = 2
const val ID_INSTALL = 3
const val CHANNEL_UPDATED = "updated_channel"
const val ID_INSTALLED = -6
/**
* Notification channel and ids used by the library updater.
@ -48,6 +50,9 @@ object Notifications {
const val CHANNEL_UPDATES_TO_EXTS = "updates_ext_channel"
const val ID_UPDATES_TO_EXTS = -401
const val CHANNEL_EXT_PROGRESS = "ext_update_progress_channel"
const val ID_EXTENSION_PROGRESS = -402
private const val GROUP_BACKUP_RESTORE = "group_backup_restore"
const val CHANNEL_BACKUP_RESTORE_PROGRESS = "backup_restore_progress_channel"
const val ID_RESTORE_PROGRESS = -501
@ -135,5 +140,25 @@ object Notifications {
)
)
context.notificationManager.createNotificationChannels(channels)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val newChannels = listOf(
NotificationChannel(
CHANNEL_EXT_PROGRESS,
context.getString(R.string.updating_extensions),
NotificationManager.IMPORTANCE_LOW
).apply {
setShowBadge(false)
setSound(null, null)
},
NotificationChannel(
CHANNEL_UPDATED,
context.getString(R.string.update_completed),
NotificationManager.IMPORTANCE_DEFAULT
).apply {
setShowBadge(false)
}
)
context.notificationManager.createNotificationChannels(newChannels)
}
}
}

View File

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

View File

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

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

View File

@ -4,9 +4,11 @@ import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import androidx.core.net.toUri
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.NotificationHandler
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
@ -44,6 +46,7 @@ internal class UpdaterNotifier(private val context: Context) {
fun promptUpdate(body: String, url: String, releaseUrl: String) {
val intent = Intent(context, UpdaterService::class.java).apply {
putExtra(UpdaterService.EXTRA_DOWNLOAD_URL, url)
putExtra(UpdaterService.EXTRA_NOTIFY_ON_INSTALL, true)
}
val pendingIntent = NotificationReceiver.openUpdatePendingActivity(context, body, url)
@ -56,10 +59,11 @@ internal class UpdaterNotifier(private val context: Context) {
setSmallIcon(android.R.drawable.stat_sys_download_done)
color = context.getResourceColor(R.attr.colorAccent)
clearActions()
val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
// Download action
addAction(
android.R.drawable.stat_sys_download_done,
context.getString(R.string.download),
context.getString(if (isOnA12) R.string.update else R.string.download),
PendingIntent.getService(
context,
0,
@ -155,6 +159,31 @@ internal class UpdaterNotifier(private val context: Context) {
notificationBuilder.show(Notifications.ID_INSTALL)
}
/**
* Call when apk download is finished.
*
* @param uri path location of apk.
*/
fun onInstallFinished() {
with(NotificationCompat.Builder(context, Notifications.CHANNEL_UPDATED)) {
setContentTitle(context.getString(R.string.updated_to_, BuildConfig.VERSION_NAME))
setSmallIcon(R.drawable.ic_tachij2k_notification)
setAutoCancel(true)
setOngoing(false)
setProgress(0, 0, false)
val pendingIntent = PendingIntent.getActivity(
context,
0,
context.packageManager.getLaunchIntentForPackage(BuildConfig.APPLICATION_ID),
PendingIntent.FLAG_UPDATE_CURRENT
)
setContentIntent(pendingIntent)
clearActions()
addReleasePageAction()
show(Notifications.ID_INSTALLED)
}
}
/**
* Call when apk download throws a error
*
@ -186,6 +215,32 @@ internal class UpdaterNotifier(private val context: Context) {
notificationBuilder.show(Notifications.ID_UPDATER)
}
fun onInstallError(uri: Uri) {
with(notificationBuilder) {
setContentText(context.getString(R.string.could_not_install_update))
setSmallIcon(android.R.drawable.stat_sys_warning)
setOnlyAlertOnce(false)
setAutoCancel(false)
setProgress(0, 0, false)
color = ContextCompat.getColor(context, R.color.colorAccent)
clearActions()
// Retry action
addAction(
R.drawable.ic_refresh_24dp,
context.getString(R.string.retry),
NotificationHandler.installApkPendingActivity(context, uri)
)
// Cancel action
addAction(
R.drawable.ic_close_24dp,
context.getString(R.string.cancel),
NotificationReceiver.dismissNotificationPendingBroadcast(context, Notifications.ID_UPDATER)
)
addReleasePageAction()
}
notificationBuilder.show(Notifications.ID_UPDATER)
}
fun cancel() {
NotificationReceiver.dismissNotification(context, Notifications.ID_UPDATER)
}

View File

@ -4,9 +4,12 @@ import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.content.edit
import androidx.preference.PreferenceManager
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications
@ -18,12 +21,11 @@ import eu.kanade.tachiyomi.network.newCallWithProgress
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.storage.saveTo
import eu.kanade.tachiyomi.util.system.acquireWakeLock
import eu.kanade.tachiyomi.util.system.isServiceRunning
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.GlobalScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import okhttp3.Call
import okhttp3.internal.http2.ErrorCode
@ -64,6 +66,8 @@ class UpdaterService : Service() {
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent == null) return START_NOT_STICKY
instance = this
val handler = CoroutineExceptionHandler { _, exception ->
Timber.e(exception)
stopSelf(startId)
@ -71,9 +75,10 @@ class UpdaterService : Service() {
val url = intent.getStringExtra(EXTRA_DOWNLOAD_URL) ?: return START_NOT_STICKY
val title = intent.getStringExtra(EXTRA_DOWNLOAD_TITLE) ?: getString(R.string.app_name)
val notifyOnInstall = intent.getBooleanExtra(EXTRA_NOTIFY_ON_INSTALL, false)
runningJob = GlobalScope.launch(handler) {
downloadApk(title, url)
downloadApk(title, url, notifyOnInstall)
}
runningJob?.invokeOnCompletion { stopSelf(startId) }
@ -88,6 +93,9 @@ class UpdaterService : Service() {
override fun onDestroy() {
destroyJob()
if (instance == this) {
instance = null
}
super.onDestroy()
}
@ -104,7 +112,7 @@ class UpdaterService : Service() {
*
* @param url url location of file
*/
private suspend fun downloadApk(title: String, url: String) {
private suspend fun downloadApk(title: String, url: String, notifyOnInstall: Boolean) {
// Show notification download starting.
notifier.onDownloadStarted(title)
@ -141,7 +149,11 @@ class UpdaterService : Service() {
response.close()
throw Exception("Unsuccessful response")
}
notifier.onDownloadFinished(apkFile.getUriCompat(this))
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
startInstalling(apkFile, notifyOnInstall)
} else {
notifier.onDownloadFinished(apkFile.getUriCompat(this))
}
} catch (error: Exception) {
Timber.e(error)
if (error is CancellationException ||
@ -154,30 +166,77 @@ class UpdaterService : Service() {
}
}
private fun startInstalling(file: File, notifyOnInstall: Boolean) {
try {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.S) return
val packageInstaller = packageManager.packageInstaller
val data = file.inputStream()
val params = PackageInstaller.SessionParams(
PackageInstaller.SessionParams.MODE_FULL_INSTALL
)
params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED)
val sessionId = packageInstaller.createSession(params)
val session = packageInstaller.openSession(sessionId)
session.openWrite("package", 0, -1).use { packageInSession ->
data.copyTo(packageInSession)
}
if (notifyOnInstall) {
val prefs = PreferenceManager.getDefaultSharedPreferences(this)
prefs.edit {
putBoolean(NOTIFY_ON_INSTALL_KEY, true)
}
}
val newIntent = Intent(this, UpdaterBroadcast::class.java)
.setAction(PACKAGE_INSTALLED_ACTION)
.putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall)
.putExtra(EXTRA_FILE_URI, file.getUriCompat(this).toString())
val pendingIntent = PendingIntent.getBroadcast(this, -10053, newIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
val statusReceiver = pendingIntent.intentSender
session.commit(statusReceiver)
data.close()
} catch (error: Exception) {
// Either install package can't be found (probably bots) or there's a security exception
// with the download manager. Nothing we can workaround.
toast(error.message)
}
}
companion object {
const val PACKAGE_INSTALLED_ACTION =
"${BuildConfig.APPLICATION_ID}.SESSION_SELF_API_PACKAGE_INSTALLED"
internal const val EXTRA_NOTIFY_ON_INSTALL = "${BuildConfig.APPLICATION_ID}.UpdaterService.ACTION_ON_INSTALL"
internal const val EXTRA_DOWNLOAD_URL = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_URL"
internal const val EXTRA_FILE_URI = "${BuildConfig.APPLICATION_ID}.UpdaterService.FILE_URI"
internal const val EXTRA_DOWNLOAD_TITLE = "${BuildConfig.APPLICATION_ID}.UpdaterService.DOWNLOAD_TITLE"
internal const val NOTIFY_ON_INSTALL_KEY = "notify_on_install_complete"
private var instance: UpdaterService? = null
/**
* Returns the status of the service.
*
* @param context the application context.
* @return true if the service is running, false otherwise.
*/
private fun isRunning(context: Context): Boolean =
context.isServiceRunning(UpdaterService::class.java)
fun isRunning(): Boolean = instance != null
/**
* Downloads a new update and let the user install the new version from a notification.
* @param context the application context.
* @param url the url to the new update.
*/
fun start(context: Context, url: String, title: String = context.getString(R.string.app_name)) {
if (!isRunning(context)) {
fun start(context: Context, url: String, notifyOnInstall: Boolean) {
if (!isRunning()) {
val title = context.getString(R.string.app_name)
val intent = Intent(context, UpdaterService::class.java).apply {
putExtra(EXTRA_DOWNLOAD_TITLE, title)
putExtra(EXTRA_DOWNLOAD_URL, url)
putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall)
}
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
context.startService(intent)
@ -202,9 +261,10 @@ class UpdaterService : Service() {
* @param url the url to the new update.
* @return [PendingIntent]
*/
internal fun downloadApkPendingService(context: Context, url: String): PendingIntent {
internal fun downloadApkPendingService(context: Context, url: String, notifyOnInstall: Boolean = false): PendingIntent {
val intent = Intent(context, UpdaterService::class.java).apply {
putExtra(EXTRA_DOWNLOAD_URL, url)
putExtra(EXTRA_NOTIFY_ON_INSTALL, notifyOnInstall)
}
return PendingIntent.getService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}

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.graphics.drawable.Drawable
import android.os.Parcelable
import com.jakewharton.rxrelay.BehaviorRelay
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -15,9 +16,12 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.ui.extension.ExtensionIntallInfo
import eu.kanade.tachiyomi.util.system.launchNow
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -47,6 +51,15 @@ class ExtensionManager(
*/
private val installer by lazy { ExtensionInstaller(context) }
val downloadRelay
get() = installer.downloadsStateFlow
fun getExtension(downloadId: Long): String? {
return installer.activeDownloads.entries.find { downloadId == it.value }?.key
}
fun getActiveInstalls(): Int = installer.activeDownloads.size
/**
* Relay used to notify the installed extensions.
*/
@ -232,27 +245,14 @@ class ExtensionManager(
}
/**
* Returns an observable of the installation process for the given extension. It will complete
* once the extension is installed or throws an error. The process will be canceled if
* unsubscribed before its completion.
* Returns a flow of the installation process for the given extension. It will complete
* once the extension is installed or throws an error. The process will be canceled the scope
* is canceled before its completion.
*
* @param extension The extension to be installed.
*/
fun installExtension(extension: Extension.Available): Observable<ExtensionIntallInfo> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension)
}
/**
* Returns an observable of the installation process for the given extension. It will complete
* once the extension is updated or throws an error. The process will be canceled if
* unsubscribed before its completion.
*
* @param extension The extension to be updated.
*/
fun updateExtension(extension: Extension.Installed): Observable<ExtensionIntallInfo> {
val availableExt = availableExtensions.find { it.pkgName == extension.pkgName }
?: return Observable.empty()
return installExtension(availableExt)
suspend fun installExtension(extension: ExtensionInfo, scope: CoroutineScope): Flow<ExtensionIntallInfo> {
return installer.downloadAndInstall(api.getApkUrl(extension), extension, scope)
}
/**
@ -405,6 +405,21 @@ class ExtensionManager(
}
return this
}
@Parcelize
data class ExtensionInfo(
val apkName: String,
val pkgName: String,
val name: String,
val versionCode: Int,
) : Parcelable {
constructor(extension: Extension.Available) : this(
apkName = extension.apkName,
pkgName = extension.pkgName,
name = extension.name,
versionCode = extension.versionCode
)
}
}
interface ExtensionsChangedListener {

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.extension
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@ -17,6 +19,7 @@ import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.extension.model.Extension
import eu.kanade.tachiyomi.util.system.notification
import kotlinx.coroutines.coroutineScope
import uy.kohesive.injekt.Injekt
@ -35,15 +38,21 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
}
if (pendingUpdates.isNotEmpty()) {
createUpdateNotification(pendingUpdates.map { it.name })
createUpdateNotification(pendingUpdates)
}
Result.success()
}
private fun createUpdateNotification(names: List<String>) {
private fun createUpdateNotification(extensions: List<Extension.Available>) {
val preferences: PreferencesHelper by injectLazy()
preferences.extensionUpdatesCount().set(names.size)
preferences.extensionUpdatesCount().set(extensions.size)
// Not doing this yet since users will get prompted while device is idle
// if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && preferences.autoUpdateExtensions()) {
// val intent = ExtensionInstallService.jobIntent(context, extensions)
// context.startForegroundService(intent)
// return
// }
NotificationManagerCompat.from(context).apply {
notify(
Notifications.ID_UPDATES_TO_EXTS,
@ -51,11 +60,11 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
setContentTitle(
context.resources.getQuantityString(
R.plurals.extension_updates_available,
names.size,
names.size
extensions.size,
extensions.size
)
)
val extNames = names.joinToString(", ")
val extNames = extensions.joinToString(", ") { it.name }
setContentText(extNames)
setStyle(NotificationCompat.BigTextStyle().bigText(extNames))
setSmallIcon(R.drawable.ic_extension_update_24dp)
@ -65,6 +74,16 @@ class ExtensionUpdateJob(private val context: Context, workerParams: WorkerParam
context
)
)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
val intent = ExtensionInstallService.jobIntent(context, extensions)
val pendingIntent =
PendingIntent.getForegroundService(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
addAction(
R.drawable.ic_file_download_24dp,
context.getString(R.string.update_all),
pendingIntent
)
}
setAutoCancel(true)
}
)

View File

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

View File

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

View File

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

View File

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

View File

@ -24,5 +24,6 @@ class ExtensionAdapter(val listener: OnButtonClickListener) :
interface OnButtonClickListener {
fun onButtonClick(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.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.executeOnIO
import eu.kanade.tachiyomi.util.system.withUIContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import rx.Observable
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
@ -38,7 +41,7 @@ typealias ExtensionIntallInfo = Pair<InstallStep, PackageInstaller.SessionInfo?>
class ExtensionBottomPresenter(
private val bottomSheet: ExtensionBottomSheet,
private val extensionManager: ExtensionManager = Injekt.get(),
private val preferences: PreferencesHelper = Injekt.get()
val preferences: PreferencesHelper = Injekt.get()
) : BaseCoroutinePresenter(), ExtensionsChangedListener {
private var extensions = emptyList<ExtensionItem>()
@ -76,7 +79,10 @@ class ExtensionBottomPresenter(
sourceItems = findSourcesWithManga(favs)
mangaItems = HashMap(
sourceItems.associate {
it.source.id to this@ExtensionBottomPresenter.libraryToMigrationItem(favs, it.source.id)
it.source.id to this@ExtensionBottomPresenter.libraryToMigrationItem(
favs,
it.source.id
)
}
)
withContext(Dispatchers.Main) {
@ -89,6 +95,27 @@ class ExtensionBottomPresenter(
}
listOf(migrationJob, extensionJob).awaitAll()
}
presenterScope.launch {
extensionManager.downloadRelay
.collect {
val extPageName = extensionManager.getExtension(it.first)
val extension = extensions.find { item ->
extPageName == item.extension.pkgName
} ?: return@collect
when (it.second.first) {
InstallStep.Installed, InstallStep.Error -> {
currentDownloads.remove(extension.extension.pkgName)
}
else -> {
currentDownloads[extension.extension.pkgName] = it.second
}
}
val item = updateInstallStep(extension.extension, it.second.first, it.second.second)
if (item != null) {
withUIContext { bottomSheet.downloadUpdate(item) }
}
}
}
}
private fun findSourcesWithManga(library: List<Manga>): List<SourceItem> {
@ -176,7 +203,8 @@ class ExtensionBottomPresenter(
updatesSorted.size,
updatesSorted.size
),
updatesSorted.size
updatesSorted.size,
items.count { it.extension.pkgName in currentDownloads.keys } != updatesSorted.size
)
items += updatesSorted.map { extension ->
ExtensionItem(extension, header, currentDownloads[extension.pkgName])
@ -215,7 +243,7 @@ class ExtensionBottomPresenter(
@Synchronized
private fun updateInstallStep(
extension: Extension,
state: InstallStep,
state: InstallStep?,
session: PackageInstaller.SessionInfo?
): ExtensionItem? {
val extensions = extensions.toMutableList()
@ -242,13 +270,21 @@ class ExtensionBottomPresenter(
fun installExtension(extension: Extension.Available) {
if (isNotMIUIOptimized()) {
extensionManager.installExtension(extension).subscribeToInstallUpdate(extension)
presenterScope.launch {
extensionManager.installExtension(ExtensionManager.ExtensionInfo(extension), presenterScope)
.launchIn(this)
}
}
}
fun updateExtension(extension: Extension.Installed) {
if (isNotMIUIOptimized()) {
extensionManager.updateExtension(extension).subscribeToInstallUpdate(extension)
val availableExt =
extensionManager.availableExtensions.find { it.pkgName == extension.pkgName } ?: return
presenterScope.launch {
extensionManager.installExtension(ExtensionManager.ExtensionInfo(availableExt), presenterScope)
.launchIn(this)
}
}
}
@ -260,17 +296,6 @@ class ExtensionBottomPresenter(
return true
}
private fun Observable<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) {
extensionManager.uninstallExtension(pkgName)
}

View File

@ -7,6 +7,7 @@ import android.view.View
import android.view.ViewGroup
import android.widget.LinearLayout
import androidx.recyclerview.widget.RecyclerView
import com.afollestad.materialdialogs.MaterialDialog
import com.google.android.material.bottomsheet.BottomSheetBehavior
import com.google.android.material.tabs.TabLayout
import eu.davidea.flexibleadapter.FlexibleAdapter
@ -201,6 +202,35 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At
presenter.cancelExtensionInstall(extension)
}
override fun onUpdateAllClicked(position: Int) {
if (!presenter.preferences.hasPromptedBeforeUpdateAll().get()) {
MaterialDialog(controller.activity!!)
.title(R.string.update_all)
.message(R.string.some_extensions_may_prompt)
.positiveButton(android.R.string.ok) {
presenter.preferences.hasPromptedBeforeUpdateAll().set(true)
updateAllExtensions(position)
}
.show()
} else {
updateAllExtensions(position)
}
}
fun updateAllExtensions(position: Int) {
val header = (extAdapter?.getSectionHeader(position)) as? ExtensionGroupItem ?: return
val items = extAdapter?.getSectionItemPositions(header)
items?.forEach {
val extItem = (extAdapter?.getItem(it) as? ExtensionItem) ?: return
val extension = (extAdapter?.getItem(it) as? ExtensionItem)?.extension ?: return
if (extItem.installStep == null &&
extension is Extension.Installed && extension.hasUpdate
) {
presenter.updateExtension(extension)
}
}
}
override fun onItemClick(view: View?, position: Int): Boolean {
when (binding.tabs.selectedTabPosition) {
0 -> {
@ -298,6 +328,7 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At
extAdapter?.updateDataSet(extensions)
}
updateExtTitle()
updateExtUpdateAllButton()
}
fun canGoBack(): Boolean {
@ -310,6 +341,20 @@ class ExtensionBottomSheet @JvmOverloads constructor(context: Context, attrs: At
fun downloadUpdate(item: ExtensionItem) {
extAdapter?.updateItem(item, item.installStep)
updateExtUpdateAllButton()
}
fun updateExtUpdateAllButton() {
val updateHeader =
extAdapter?.headerItems?.find { it is ExtensionGroupItem && it.canUpdate != null } as? ExtensionGroupItem
?: return
val items = extAdapter?.getSectionItemPositions(updateHeader) ?: return
updateHeader.canUpdate = items.any {
val extItem = (extAdapter?.getItem(it) as? ExtensionItem) ?: return
val extension = (extAdapter?.getItem(it) as? ExtensionItem)?.extension ?: return
extItem.installStep == null
}
extAdapter?.updateItem(updateHeader)
}
override fun trustSignature(signatureHash: String) {

View File

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

View File

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

View File

@ -3,10 +3,12 @@ package eu.kanade.tachiyomi.ui.main
import android.animation.AnimatorSet
import android.animation.ValueAnimator
import android.app.Dialog
import android.app.assist.AssistContent
import android.content.Intent
import android.graphics.Color
import android.graphics.Rect
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.os.Handler
@ -55,6 +57,7 @@ import eu.kanade.tachiyomi.data.updater.UpdateResult
import eu.kanade.tachiyomi.data.updater.UpdaterNotifier
import eu.kanade.tachiyomi.databinding.MainActivityBinding
import eu.kanade.tachiyomi.extension.api.ExtensionGithubApi
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet
import eu.kanade.tachiyomi.ui.base.activity.BaseActivity
import eu.kanade.tachiyomi.ui.base.controller.BaseController
@ -647,6 +650,25 @@ open class MainActivity : BaseActivity<MainActivityBinding>(), DownloadServiceLi
return true
}
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
when (val controller = router.backstack.lastOrNull()?.controller) {
is MangaDetailsController -> {
val source = controller.presenter.source as? HttpSource ?: return
val url = try {
source.mangaDetailsRequest(controller.presenter.manga).url.toString()
} catch (e: Exception) {
return
}
outContent.webUri = Uri.parse(url)
}
is BrowseSourceController -> {
val source = controller.presenter.source as? HttpSource ?: return
outContent.webUri = Uri.parse(source.baseUrl)
}
}
}
override fun onDestroy() {
super.onDestroy()
overflowDialog?.dismiss()

View File

@ -4,6 +4,9 @@ import android.annotation.SuppressLint
import android.content.res.ColorStateList
import android.graphics.Color
import android.view.LayoutInflater
import android.graphics.RenderEffect
import android.graphics.Shader
import android.os.Build
import android.view.MotionEvent
import android.view.View
import androidx.constraintlayout.widget.ConstraintLayout
@ -124,6 +127,15 @@ class MangaHeaderHolder(
)
true
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
backdrop.setRenderEffect(
RenderEffect.createBlurEffect(
10f,
10f,
Shader.TileMode.MIRROR
)
)
}
mangaCover.setOnClickListener { adapter.delegate.zoomImageFromThumb(coverCard) }
trackButton.setOnClickListener { adapter.delegate.showTrackingSheet() }
if (startExpanded) expandDesc()

View File

@ -1,6 +1,7 @@
package eu.kanade.tachiyomi.ui.reader
import android.annotation.SuppressLint
import android.app.assist.AssistContent
import android.content.ClipData
import android.content.Context
import android.content.Intent
@ -9,6 +10,7 @@ import android.content.res.Configuration
import android.graphics.Bitmap
import android.graphics.Color
import android.graphics.drawable.LayerDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import android.view.KeyEvent
@ -41,6 +43,7 @@ import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn
import eu.kanade.tachiyomi.data.preference.toggle
import eu.kanade.tachiyomi.databinding.ReaderActivityBinding
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.base.MaterialMenuSheet
import eu.kanade.tachiyomi.ui.base.activity.BaseRxActivity
import eu.kanade.tachiyomi.ui.main.MainActivity
@ -1225,6 +1228,18 @@ class ReaderActivity :
startActivity(Intent.createChooser(intent, getString(R.string.share)))
}
override fun onProvideAssistContent(outContent: AssistContent) {
super.onProvideAssistContent(outContent)
val manga = presenter.manga ?: return
val source = presenter.source as? HttpSource ?: return
val url = try {
source.mangaDetailsRequest(manga).url.toString()
} catch (e: Exception) {
return
}
outContent.webUri = Uri.parse(url)
}
/**
* Called from the page sheet. It delegates saving the image of the given [page] on external
* storage to the presenter.

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.TrackManager
import eu.kanade.tachiyomi.source.LocalSource
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.online.HttpSource
@ -76,6 +77,9 @@ class ReaderPresenter(
var manga: Manga? = null
private set
val source: Source?
get() = manga?.source?.let { sourceManager.getOrStub(it) }
/**
* The chapter id of the currently loaded chapter. Used to restore from process kill.
*/

View File

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

View File

@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.ui.setting
import android.os.Build
import android.os.Bundle
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
@ -8,6 +9,7 @@ import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.preference.asImmediateFlow
import eu.kanade.tachiyomi.data.preference.asImmediateFlowIn
import eu.kanade.tachiyomi.data.updater.AutoUpdaterJob
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.system.LocaleHelper
import eu.kanade.tachiyomi.util.system.appDelegateNightMode
@ -270,6 +272,20 @@ class SettingsGeneralController : SettingsController() {
defaultValue = ""
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S && isUpdaterEnabled) {
preferenceCategory {
titleRes = R.string.auto_updates
intListPreference(activity) {
key = Keys.shouldAutoUpdate
titleRes = R.string.auto_update_app
entryRange = 0..2
entriesRes = arrayOf(R.string.over_any_network, R.string.over_wifi_only, R.string.dont_auto_update)
defaultValue = AutoUpdaterJob.ONLY_ON_UNMETERED
}
}
}
}
override fun onDestroyView(view: View) {

View File

@ -4,6 +4,7 @@ import android.app.Activity
import android.content.Context
import android.content.res.ColorStateList
import android.graphics.Color
import android.os.Build
import android.util.AttributeSet
import android.view.View
import androidx.appcompat.app.AppCompatDelegate
@ -26,6 +27,7 @@ import eu.kanade.tachiyomi.databinding.ThemesPreferenceBinding
import eu.kanade.tachiyomi.util.system.ThemeUtil
import eu.kanade.tachiyomi.util.system.Themes
import eu.kanade.tachiyomi.util.system.appDelegateNightMode
import eu.kanade.tachiyomi.util.system.contextCompatColor
import eu.kanade.tachiyomi.util.system.dpToPx
import eu.kanade.tachiyomi.util.system.isInNightMode
import uy.kohesive.injekt.injectLazy
@ -56,14 +58,15 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu
selectExtensionLight = fastAdapterLight.getSelectExtension().setThemeListener(false)
selectExtensionDark = fastAdapterDark.getSelectExtension().setThemeListener(true)
val enumConstants = Themes.values()
val isOnA12 = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
itemAdapterLight.set(
enumConstants
.filter { !it.isDarkTheme || it.followsSystem }
.filter { (!it.isDarkTheme || it.followsSystem) && (it.styleRes != R.style.Theme_Tachiyomi_Monet || isOnA12) }
.map { ThemeItem(it, false) }
)
itemAdapterDark.set(
enumConstants
.filter { it.isDarkTheme || it.followsSystem }
.filter { (it.isDarkTheme || it.followsSystem) && (it.styleRes != R.style.Theme_Tachiyomi_Monet || isOnA12) }
.map { ThemeItem(it, true) }
)
isSelectable = false
@ -207,6 +210,7 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu
inner class ViewHolder(view: View) : FastAdapter.ViewHolder<ThemeItem>(view) {
val binding = ThemeItemBinding.bind(view)
override fun bindView(item: ThemeItem, payloads: List<Any>) {
binding.themeNameText.setText(
if (item.isDarkTheme) {
@ -228,27 +232,75 @@ class ThemePreference @JvmOverloads constructor(context: Context, attrs: Attribu
binding.themeSelected.alpha = if (themeMatchesApp) 1f else 0.5f
binding.checkbox.alpha = if (themeMatchesApp) 1f else 0.5f
}
binding.themeToolbar.setBackgroundColor(item.colors.appBar)
binding.themeAppBarText.imageTintList =
ColorStateList.valueOf(item.colors.appBarText)
binding.themeHeroImage.imageTintList =
ColorStateList.valueOf(item.colors.primaryText)
binding.themePrimaryText.imageTintList =
ColorStateList.valueOf(item.colors.primaryText)
binding.themeAccentedButton.imageTintList =
ColorStateList.valueOf(item.colors.colorAccent)
binding.themeSecondaryText.imageTintList =
ColorStateList.valueOf(item.colors.secondaryText)
binding.themeSecondaryText2.imageTintList =
ColorStateList.valueOf(item.colors.secondaryText)
if (item.theme.styleRes == R.style.Theme_Tachiyomi_Monet &&
Build.VERSION.SDK_INT >= Build.VERSION_CODES.S
) {
val nightMode = item.isDarkTheme
val appBar = context.contextCompatColor(
if (nightMode) android.R.color.system_neutral1_900
else android.R.color.system_neutral1_50
)
val appBarText = context.contextCompatColor(
if (nightMode) android.R.color.system_accent2_10
else android.R.color.system_accent2_800
)
val colorAccent = context.contextCompatColor(
if (nightMode) android.R.color.system_accent1_300
else android.R.color.system_accent1_500
)
val bottomBar = context.contextCompatColor(
if (nightMode) android.R.color.system_neutral1_800
else android.R.color.system_accent2_100
)
val colorBackground = context.contextCompatColor(
if (nightMode) android.R.color.system_neutral1_900
else android.R.color.system_neutral1_50
)
binding.themeBottomBar.setBackgroundColor(item.colors.bottomBar)
binding.themeItem1.imageTintList =
ColorStateList.valueOf(item.colors.inactiveTab)
binding.themeItem2.imageTintList = ColorStateList.valueOf(item.colors.activeTab)
binding.themeItem3.imageTintList =
ColorStateList.valueOf(item.colors.inactiveTab)
binding.themeLayout.setBackgroundColor(item.colors.colorBackground)
binding.themeToolbar.setBackgroundColor(appBar)
binding.themeAppBarText.imageTintList =
ColorStateList.valueOf(appBarText)
binding.themeHeroImage.imageTintList =
ColorStateList.valueOf(item.colors.primaryText)
binding.themePrimaryText.imageTintList =
ColorStateList.valueOf(item.colors.primaryText)
binding.themeAccentedButton.imageTintList =
ColorStateList.valueOf(colorAccent)
binding.themeSecondaryText.imageTintList =
ColorStateList.valueOf(item.colors.secondaryText)
binding.themeSecondaryText2.imageTintList =
ColorStateList.valueOf(item.colors.secondaryText)
binding.themeBottomBar.setBackgroundColor(bottomBar)
binding.themeItem1.imageTintList =
ColorStateList.valueOf(item.colors.inactiveTab)
binding.themeItem2.imageTintList = ColorStateList.valueOf(colorAccent)
binding.themeItem3.imageTintList =
ColorStateList.valueOf(item.colors.inactiveTab)
binding.themeLayout.setBackgroundColor(colorBackground)
} else {
binding.themeToolbar.setBackgroundColor(item.colors.appBar)
binding.themeAppBarText.imageTintList =
ColorStateList.valueOf(item.colors.appBarText)
binding.themeHeroImage.imageTintList =
ColorStateList.valueOf(item.colors.primaryText)
binding.themePrimaryText.imageTintList =
ColorStateList.valueOf(item.colors.primaryText)
binding.themeAccentedButton.imageTintList =
ColorStateList.valueOf(item.colors.colorAccent)
binding.themeSecondaryText.imageTintList =
ColorStateList.valueOf(item.colors.secondaryText)
binding.themeSecondaryText2.imageTintList =
ColorStateList.valueOf(item.colors.secondaryText)
binding.themeBottomBar.setBackgroundColor(item.colors.bottomBar)
binding.themeItem1.imageTintList =
ColorStateList.valueOf(item.colors.inactiveTab)
binding.themeItem2.imageTintList = ColorStateList.valueOf(item.colors.activeTab)
binding.themeItem3.imageTintList =
ColorStateList.valueOf(item.colors.inactiveTab)
binding.themeLayout.setBackgroundColor(item.colors.colorBackground)
}
if (item.isDarkTheme && preferences.themeDarkAmoled().get()) {
binding.themeLayout.setBackgroundColor(Color.BLACK)
if (!ThemeUtil.isColoredTheme(item.theme)) {

View File

@ -11,6 +11,12 @@ import kotlin.math.roundToInt
@Suppress("unused")
enum class Themes(@StyleRes val styleRes: Int, val nightMode: Int, @StringRes val nameRes: Int, @StringRes altNameRes: Int? = null) {
MONET(
R.style.Theme_Tachiyomi_Monet,
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,
R.string.a_brighter_you,
R.string.a_calmer_you
),
DEFAULT(
R.style.Theme_Tachiyomi,
AppCompatDelegate.MODE_NIGHT_FOLLOW_SYSTEM,

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"
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>
</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="download">@color/material_green_700</color>
<color name="holo_red">#cc4444</color>
<color name="splashIcon">@color/md_white_1000</color>
<color name="background">#1C1C1D</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="dialog">@color/md_white_1000</color>
<color name="splashIcon">@color/md_black_1000</color>
<!-- Text Colors -->
<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="searching_for_updates">Searching for updates…</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 -->
@ -312,6 +314,9 @@
<string name="app_info">App info</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="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">
<item quantity="one">%d update pending</item>
<item quantity="other">%d updates pending</item>
@ -625,6 +630,8 @@
<string name="midnight_dusk">Midnight Dusk</string>
<string name="spring_blossom">Spring Blossom</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="yin">Yin</string>
<string name="yang">Yang</string>
@ -651,6 +658,11 @@
<string name="starting_screen">Starting screen</string>
<string name="back_to_start">Back to start</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="show_recent_sources">Show recently used sources</string>
@ -980,5 +992,6 @@
<string name="use_default">Use default</string>
<string name="view_all_errors">View all errors</string>
<string name="view_chapters">View chapters</string>
<string name="warning">Warning</string>
<string name="wifi">Wi-Fi</string>
</resources>

View File

@ -10,6 +10,9 @@
<item name="android:forceDarkAllowed" tools:targetApi="29">false</item>
<item name="android:enforceNavigationBarContrast" tools:targetApi="29">false</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="colorPrimaryVariant">@color/colorPrimary</item>
<item name="colorSecondary">@color/background</item>
@ -105,6 +108,7 @@
<item name="colorAccentText">@color/colorAccentYinyangText</item>
<item name="colorOnAccent">@color/colorOnAccentYinyang</item>
</style>
<style name="Theme.Tachiyomi.Monet"/>
<!--===============-->
<!-- Launch Screen -->

View File

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