Convert BackupRestoreService to a WorkManager job

Co-authored-by: Jays2Kings <Jays2Kings@users.noreply.github.com>
This commit is contained in:
arkon 2023-03-19 17:28:59 -04:00
parent 14d1bcacc9
commit cdc160afc2
11 changed files with 124 additions and 198 deletions

View File

@ -209,10 +209,6 @@
android:name=".data.updater.AppUpdateService" android:name=".data.updater.AppUpdateService"
android:exported="false" /> android:exported="false" />
<service
android:name=".data.backup.BackupRestoreService"
android:exported="false" />
<service android:name=".extension.util.ExtensionInstallService" <service android:name=".extension.util.ExtensionInstallService"
android:exported="false" /> android:exported="false" />

View File

@ -41,9 +41,9 @@ import eu.kanade.presentation.more.settings.Preference
import eu.kanade.presentation.util.collectAsState import eu.kanade.presentation.util.collectAsState
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupConst import eu.kanade.tachiyomi.data.backup.BackupConst
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupCreateJob
import eu.kanade.tachiyomi.data.backup.BackupFileValidator import eu.kanade.tachiyomi.data.backup.BackupFileValidator
import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.backup.BackupRestoreJob
import eu.kanade.tachiyomi.data.backup.models.Backup import eu.kanade.tachiyomi.data.backup.models.Backup
import eu.kanade.tachiyomi.util.storage.DiskUtil import eu.kanade.tachiyomi.util.storage.DiskUtil
import eu.kanade.tachiyomi.util.system.DeviceUtil import eu.kanade.tachiyomi.util.system.DeviceUtil
@ -93,7 +93,7 @@ object SettingsBackupScreen : SearchableSettings {
Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_READ_URI_PERMISSION or
Intent.FLAG_GRANT_WRITE_URI_PERMISSION, Intent.FLAG_GRANT_WRITE_URI_PERMISSION,
) )
BackupCreatorJob.startNow(context, it, flag) BackupCreateJob.startNow(context, it, flag)
} }
flag = 0 flag = 0
} }
@ -119,7 +119,7 @@ object SettingsBackupScreen : SearchableSettings {
subtitle = stringResource(R.string.pref_create_backup_summ), subtitle = stringResource(R.string.pref_create_backup_summ),
onClick = { onClick = {
scope.launch { scope.launch {
if (!BackupCreatorJob.isManualJobRunning(context)) { if (!BackupCreateJob.isManualJobRunning(context)) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG) context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG)
} }
@ -271,7 +271,7 @@ object SettingsBackupScreen : SearchableSettings {
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
BackupRestoreService.start(context, err.uri) BackupRestoreJob.start(context, err.uri)
onDismissRequest() onDismissRequest()
}, },
) { ) {
@ -301,7 +301,7 @@ object SettingsBackupScreen : SearchableSettings {
} }
if (results.missingSources.isEmpty() && results.missingTrackers.isEmpty()) { if (results.missingSources.isEmpty() && results.missingTrackers.isEmpty()) {
BackupRestoreService.start(context, it) BackupRestoreJob.start(context, it)
return@rememberLauncherForActivityResult return@rememberLauncherForActivityResult
} }
@ -313,7 +313,7 @@ object SettingsBackupScreen : SearchableSettings {
title = stringResource(R.string.pref_restore_backup), title = stringResource(R.string.pref_restore_backup),
subtitle = stringResource(R.string.pref_restore_backup_summ), subtitle = stringResource(R.string.pref_restore_backup_summ),
onClick = { onClick = {
if (!BackupRestoreService.isRunning(context)) { if (!BackupRestoreJob.isRunning(context)) {
if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) { if (DeviceUtil.isMiui && DeviceUtil.isMiuiOptimizationDisabled()) {
context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG) context.toast(R.string.restore_miui_warning, Toast.LENGTH_LONG)
} }
@ -364,7 +364,7 @@ object SettingsBackupScreen : SearchableSettings {
168 to stringResource(R.string.update_weekly), 168 to stringResource(R.string.update_weekly),
), ),
onValueChanged = { onValueChanged = {
BackupCreatorJob.setupTask(context, it) BackupCreateJob.setupTask(context, it)
true true
}, },
), ),

View File

@ -11,11 +11,11 @@ import androidx.core.content.ContextCompat
import androidx.core.graphics.drawable.toBitmap import androidx.core.graphics.drawable.toBitmap
/** /**
* Create a BitmapPainter from an drawable resource. * Create a BitmapPainter from a drawable resource.
* * Use this only if [androidx.compose.ui.res.painterResource] doesn't work.
* > Only use this if [androidx.compose.ui.res.painterResource] doesn't work.
* *
* @param id the resource identifier * @param id the resource identifier
*
* @return the bitmap associated with the resource * @return the bitmap associated with the resource
*/ */
@Composable @Composable

View File

@ -8,7 +8,7 @@ import eu.kanade.domain.base.BasePreferences
import eu.kanade.domain.source.service.SourcePreferences import eu.kanade.domain.source.service.SourcePreferences
import eu.kanade.domain.ui.UiPreferences import eu.kanade.domain.ui.UiPreferences
import eu.kanade.tachiyomi.core.security.SecurityPreferences import eu.kanade.tachiyomi.core.security.SecurityPreferences
import eu.kanade.tachiyomi.data.backup.BackupCreatorJob import eu.kanade.tachiyomi.data.backup.BackupCreateJob
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.preference.PreferenceValues import eu.kanade.tachiyomi.data.preference.PreferenceValues
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
@ -57,7 +57,7 @@ object Migrations {
// Always set up background tasks to ensure they're running // Always set up background tasks to ensure they're running
LibraryUpdateJob.setupTask(context) LibraryUpdateJob.setupTask(context)
BackupCreatorJob.setupTask(context) BackupCreateJob.setupTask(context)
// Fresh install // Fresh install
if (oldVersion == 0) { if (oldVersion == 0) {
@ -99,7 +99,7 @@ object Migrations {
if (oldVersion < 43) { if (oldVersion < 43) {
// Restore jobs after migrating from Evernote's job scheduler to WorkManager. // Restore jobs after migrating from Evernote's job scheduler to WorkManager.
LibraryUpdateJob.setupTask(context) LibraryUpdateJob.setupTask(context)
BackupCreatorJob.setupTask(context) BackupCreateJob.setupTask(context)
} }
if (oldVersion < 44) { if (oldVersion < 44) {
// Reset sorting preference if using removed sort by source // Reset sorting preference if using removed sort by source
@ -249,7 +249,7 @@ object Migrations {
} }
} }
if (oldVersion < 76) { if (oldVersion < 76) {
BackupCreatorJob.setupTask(context) BackupCreateJob.setupTask(context)
} }
if (oldVersion < 77) { if (oldVersion < 77) {
val oldReaderTap = prefs.getBoolean("reader_tap", false) val oldReaderTap = prefs.getBoolean("reader_tap", false)
@ -284,7 +284,7 @@ object Migrations {
} }
if (backupPreferences.backupInterval().get() == 0) { if (backupPreferences.backupInterval().get() == 0) {
backupPreferences.backupInterval().set(12) backupPreferences.backupInterval().set(12)
BackupCreatorJob.setupTask(context) BackupCreateJob.setupTask(context)
} }
} }
if (oldVersion < 85) { if (oldVersion < 85) {

View File

@ -23,7 +23,7 @@ import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get import uy.kohesive.injekt.api.get
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class BackupCreatorJob(private val context: Context, workerParams: WorkerParameters) : class BackupCreateJob(private val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) { CoroutineWorker(context, workerParams) {
private val notifier = BackupNotifier(context) private val notifier = BackupNotifier(context)
@ -41,7 +41,6 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet
logcat(LogPriority.ERROR, e) { "Not allowed to run on foreground service" } logcat(LogPriority.ERROR, e) { "Not allowed to run on foreground service" }
} }
context.notificationManager.notify(Notifications.ID_BACKUP_PROGRESS, notifier.showBackupProgress().build())
return try { return try {
val location = BackupManager(context).createBackup(uri, flags, isAutoBackup) val location = BackupManager(context).createBackup(uri, flags, isAutoBackup)
if (!isAutoBackup) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri())) if (!isAutoBackup) notifier.showBackupComplete(UniFile.fromUri(context, location.toUri()))
@ -70,7 +69,7 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet
val interval = prefInterval ?: backupPreferences.backupInterval().get() val interval = prefInterval ?: backupPreferences.backupInterval().get()
val workManager = WorkManager.getInstance(context) val workManager = WorkManager.getInstance(context)
if (interval > 0) { if (interval > 0) {
val request = PeriodicWorkRequestBuilder<BackupCreatorJob>( val request = PeriodicWorkRequestBuilder<BackupCreateJob>(
interval.toLong(), interval.toLong(),
TimeUnit.HOURS, TimeUnit.HOURS,
10, 10,
@ -92,7 +91,7 @@ class BackupCreatorJob(private val context: Context, workerParams: WorkerParamet
LOCATION_URI_KEY to uri.toString(), LOCATION_URI_KEY to uri.toString(),
BACKUP_FLAGS_KEY to flags, BACKUP_FLAGS_KEY to flags,
) )
val request = OneTimeWorkRequestBuilder<BackupCreatorJob>() val request = OneTimeWorkRequestBuilder<BackupCreateJob>()
.addTag(TAG_MANUAL) .addTag(TAG_MANUAL)
.setInputData(inputData) .setInputData(inputData)
.build() .build()

View File

@ -67,9 +67,7 @@ class BackupNotifier(private val context: Context) {
setContentTitle(context.getString(R.string.backup_created)) setContentTitle(context.getString(R.string.backup_created))
setContentText(unifile.filePath ?: unifile.name) setContentText(unifile.filePath ?: unifile.name)
// Clear old actions if they exist
clearActions() clearActions()
addAction( addAction(
R.drawable.ic_share_24dp, R.drawable.ic_share_24dp,
context.getString(R.string.action_share), context.getString(R.string.action_share),
@ -91,12 +89,10 @@ class BackupNotifier(private val context: Context) {
setProgress(maxAmount, progress, false) setProgress(maxAmount, progress, false)
setOnlyAlertOnce(true) setOnlyAlertOnce(true)
// Clear old actions if they exist
clearActions() clearActions()
addAction( addAction(
R.drawable.ic_close_24dp, R.drawable.ic_close_24dp,
context.getString(R.string.action_stop), context.getString(R.string.action_cancel),
NotificationReceiver.cancelRestorePendingBroadcast(context, Notifications.ID_RESTORE_PROGRESS), NotificationReceiver.cancelRestorePendingBroadcast(context, Notifications.ID_RESTORE_PROGRESS),
) )
} }
@ -132,9 +128,7 @@ class BackupNotifier(private val context: Context) {
setContentTitle(context.getString(R.string.restore_completed)) setContentTitle(context.getString(R.string.restore_completed))
setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount)) setContentText(context.resources.getQuantityString(R.plurals.restore_completed_message, errorCount, timeString, errorCount))
// Clear old actions if they exist
clearActions() clearActions()
if (errorCount > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) { if (errorCount > 0 && !path.isNullOrEmpty() && !file.isNullOrEmpty()) {
val destFile = File(path, file) val destFile = File(path, file)
val uri = destFile.getUriCompat(context) val uri = destFile.getUriCompat(context)

View File

@ -0,0 +1,87 @@
package eu.kanade.tachiyomi.data.backup
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import androidx.work.CoroutineWorker
import androidx.work.ExistingWorkPolicy
import androidx.work.ForegroundInfo
import androidx.work.OneTimeWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.workDataOf
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.notificationManager
import kotlinx.coroutines.CancellationException
import logcat.LogPriority
import tachiyomi.core.util.system.logcat
class BackupRestoreJob(private val context: Context, workerParams: WorkerParameters) :
CoroutineWorker(context, workerParams) {
private val notifier = BackupNotifier(context)
override suspend fun doWork(): Result {
val uri = inputData.getString(LOCATION_URI_KEY)?.toUri()
?: return Result.failure()
try {
setForeground(getForegroundInfo())
} catch (e: IllegalStateException) {
logcat(LogPriority.ERROR, e) { "Not allowed to run on foreground service" }
}
return try {
val restorer = BackupRestorer(context, notifier)
restorer.restoreBackup(uri)
Result.success()
} catch (e: Exception) {
if (e is CancellationException) {
notifier.showRestoreError(context.getString(R.string.restoring_backup_canceled))
Result.success()
} else {
logcat(LogPriority.ERROR, e)
notifier.showRestoreError(e.message)
Result.failure()
}
} finally {
context.notificationManager.cancel(Notifications.ID_RESTORE_PROGRESS)
}
}
override suspend fun getForegroundInfo(): ForegroundInfo {
return ForegroundInfo(
Notifications.ID_RESTORE_PROGRESS,
notifier.showRestoreProgress().build(),
)
}
companion object {
fun isRunning(context: Context): Boolean {
val list = WorkManager.getInstance(context).getWorkInfosByTag(TAG).get()
return list.find { it.state == WorkInfo.State.RUNNING } != null
}
fun start(context: Context, uri: Uri) {
val inputData = workDataOf(
LOCATION_URI_KEY to uri.toString(),
)
val request = OneTimeWorkRequestBuilder<BackupRestoreJob>()
.addTag(TAG)
.setInputData(inputData)
.build()
WorkManager.getInstance(context)
.enqueueUniqueWork(TAG, ExistingWorkPolicy.KEEP, request)
}
fun stop(context: Context) {
WorkManager.getInstance(context).cancelUniqueWork(TAG)
}
}
}
private const val TAG = "BackupRestore"
private const val LOCATION_URI_KEY = "location_uri" // String

View File

@ -1,141 +0,0 @@
package eu.kanade.tachiyomi.data.backup
import android.app.Service
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.IBinder
import android.os.PowerManager
import androidx.core.content.ContextCompat
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.util.system.acquireWakeLock
import eu.kanade.tachiyomi.util.system.getParcelableExtraCompat
import eu.kanade.tachiyomi.util.system.isServiceRunning
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.util.system.logcat
/**
* Restores backup.
*/
class BackupRestoreService : Service() {
companion object {
/**
* Returns the status of the service.
*
* @param context the application context.
* @return true if the service is running, false otherwise.
*/
fun isRunning(context: Context): Boolean =
context.isServiceRunning(BackupRestoreService::class.java)
/**
* Starts a service to restore a backup from Json
*
* @param context context of application
* @param uri path of Uri
*/
fun start(context: Context, uri: Uri) {
if (isRunning(context)) return
val intent = Intent(context, BackupRestoreService::class.java).apply {
putExtra(BackupConst.EXTRA_URI, uri)
}
ContextCompat.startForegroundService(context, intent)
}
/**
* Stops the service.
*
* @param context the application context.
*/
fun stop(context: Context) {
context.stopService(Intent(context, BackupRestoreService::class.java))
BackupNotifier(context).showRestoreError(context.getString(R.string.restoring_backup_canceled))
}
}
/**
* Wake lock that will be held until the service is destroyed.
*/
private lateinit var wakeLock: PowerManager.WakeLock
private lateinit var scope: CoroutineScope
private var restorer: BackupRestorer? = null
private lateinit var notifier: BackupNotifier
override fun onCreate() {
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
notifier = BackupNotifier(this)
wakeLock = acquireWakeLock(javaClass.name)
startForeground(Notifications.ID_RESTORE_PROGRESS, notifier.showRestoreProgress().build())
}
override fun stopService(name: Intent?): Boolean {
destroyJob()
return super.stopService(name)
}
override fun onDestroy() {
destroyJob()
}
private fun destroyJob() {
restorer?.job?.cancel()
scope.cancel()
if (wakeLock.isHeld) {
wakeLock.release()
}
}
/**
* This method needs to be implemented, but it's not used/needed.
*/
override fun onBind(intent: Intent): IBinder? = 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 {
val uri = intent?.getParcelableExtraCompat<Uri>(BackupConst.EXTRA_URI) ?: return START_NOT_STICKY
// Cancel any previous job if needed.
restorer?.job?.cancel()
restorer = BackupRestorer(this, notifier)
val handler = CoroutineExceptionHandler { _, exception ->
logcat(LogPriority.ERROR, exception)
restorer?.writeErrorLog()
notifier.showRestoreError(exception.message)
stopSelf(startId)
}
val job = scope.launch(handler) {
if (restorer?.restoreBackup(uri) == false) {
notifier.showRestoreError(getString(R.string.restoring_backup_canceled))
}
}
job.invokeOnCompletion {
stopSelf(startId)
}
restorer?.job = job
return START_NOT_STICKY
}
}

View File

@ -9,8 +9,8 @@ import eu.kanade.tachiyomi.data.backup.models.BackupManga
import eu.kanade.tachiyomi.data.backup.models.BackupSource import eu.kanade.tachiyomi.data.backup.models.BackupSource
import eu.kanade.tachiyomi.util.BackupUtil import eu.kanade.tachiyomi.util.BackupUtil
import eu.kanade.tachiyomi.util.system.createFileInCacheDir import eu.kanade.tachiyomi.util.system.createFileInCacheDir
import kotlinx.coroutines.Job import kotlinx.coroutines.coroutineScope
import okio.source import kotlinx.coroutines.isActive
import tachiyomi.domain.chapter.model.Chapter import tachiyomi.domain.chapter.model.Chapter
import tachiyomi.domain.manga.model.Manga import tachiyomi.domain.manga.model.Manga
import tachiyomi.domain.track.model.Track import tachiyomi.domain.track.model.Track
@ -24,8 +24,6 @@ class BackupRestorer(
private val notifier: BackupNotifier, private val notifier: BackupNotifier,
) { ) {
var job: Job? = null
private var backupManager = BackupManager(context) private var backupManager = BackupManager(context)
private var restoreAmount = 0 private var restoreAmount = 0
@ -56,7 +54,7 @@ class BackupRestorer(
return true return true
} }
fun writeErrorLog(): File { private fun writeErrorLog(): File {
try { try {
if (errors.isNotEmpty()) { if (errors.isNotEmpty()) {
val file = context.createFileInCacheDir("tachiyomi_restore.txt") val file = context.createFileInCacheDir("tachiyomi_restore.txt")
@ -90,18 +88,18 @@ class BackupRestorer(
val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources val backupMaps = backup.backupBrokenSources.map { BackupSource(it.name, it.sourceId) } + backup.backupSources
sourceMapping = backupMaps.associate { it.sourceId to it.name } sourceMapping = backupMaps.associate { it.sourceId to it.name }
// Restore individual manga return coroutineScope {
backup.backupManga.forEach { // Restore individual manga
if (job?.isActive != true) { backup.backupManga.forEach {
return false if (!isActive) {
return@coroutineScope false
}
restoreManga(it, backup.backupCategories)
} }
// TODO: optionally trigger online library + tracker update
restoreManga(it, backup.backupCategories) true
} }
// TODO: optionally trigger online library + tracker update
return true
} }
private suspend fun restoreCategories(backupCategories: List<BackupCategory>) { private suspend fun restoreCategories(backupCategories: List<BackupCategory>) {

View File

@ -6,10 +6,9 @@ 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 android.os.Build
import androidx.core.content.ContextCompat
import androidx.core.net.toUri import androidx.core.net.toUri
import eu.kanade.tachiyomi.R import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.backup.BackupRestoreService import eu.kanade.tachiyomi.data.backup.BackupRestoreJob
import eu.kanade.tachiyomi.data.download.DownloadManager import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.library.LibraryUpdateJob import eu.kanade.tachiyomi.data.library.LibraryUpdateJob
import eu.kanade.tachiyomi.data.updater.AppUpdateService import eu.kanade.tachiyomi.data.updater.AppUpdateService
@ -82,10 +81,7 @@ class NotificationReceiver : BroadcastReceiver() {
"application/x-protobuf+gzip", "application/x-protobuf+gzip",
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1), intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1),
) )
ACTION_CANCEL_RESTORE -> cancelRestore( ACTION_CANCEL_RESTORE -> cancelRestore(context)
context,
intent.getIntExtra(EXTRA_NOTIFICATION_ID, -1),
)
// Cancel library update and dismiss notification // Cancel library update and dismiss notification
ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context) ACTION_CANCEL_LIBRARY_UPDATE -> cancelLibraryUpdate(context)
// Cancel downloading app update // Cancel downloading app update
@ -206,11 +202,9 @@ class NotificationReceiver : BroadcastReceiver() {
* Method called when user wants to stop a backup restore job. * Method called when user wants to stop a backup restore job.
* *
* @param context context of application * @param context context of application
* @param notificationId id of notification
*/ */
private fun cancelRestore(context: Context, notificationId: Int) { private fun cancelRestore(context: Context) {
BackupRestoreService.stop(context) BackupRestoreJob.stop(context)
ContextCompat.getMainExecutor(context).execute { dismissNotification(context, notificationId) }
} }
/** /**

View File

@ -86,7 +86,6 @@
<string name="delete_category">Delete category</string> <string name="delete_category">Delete category</string>
<string name="action_edit_cover">Edit cover</string> <string name="action_edit_cover">Edit cover</string>
<string name="action_view_chapters">View chapters</string> <string name="action_view_chapters">View chapters</string>
<string name="action_stop">Stop</string>
<string name="action_pause">Pause</string> <string name="action_pause">Pause</string>
<string name="action_previous_chapter">Previous chapter</string> <string name="action_previous_chapter">Previous chapter</string>
<string name="action_next_chapter">Next chapter</string> <string name="action_next_chapter">Next chapter</string>