Show notification with error log on update failures + Move notification logic out of LibraryUpdateService

This commit is contained in:
arkon 2020-05-21 02:18:51 -04:00 committed by Jay
parent e76805160c
commit 12b2da9058
9 changed files with 318 additions and 190 deletions

View File

@ -0,0 +1,242 @@
package eu.kanade.tachiyomi.data.library
import android.app.Notification
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import coil.Coil
import coil.request.CachePolicy
import coil.request.GetRequest
import coil.transform.CircleCropTransformation
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Chapter
import eu.kanade.tachiyomi.data.database.models.LibraryManga
import eu.kanade.tachiyomi.data.database.models.Manga
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.lang.chop
import eu.kanade.tachiyomi.util.system.notification
import eu.kanade.tachiyomi.util.system.notificationBuilder
import eu.kanade.tachiyomi.util.system.notificationManager
import uy.kohesive.injekt.injectLazy
import java.util.ArrayList
class LibraryUpdateNotifier(private val context: Context) {
private val preferences: PreferencesHelper by injectLazy()
/**
* Pending intent of action that cancels the library update
*/
private val cancelIntent by lazy {
NotificationReceiver.cancelLibraryUpdatePendingBroadcast(context)
}
/**
* Bitmap of the app for notifications.
*/
private val notificationBitmap by lazy {
BitmapFactory.decodeResource(context.resources, R.mipmap.ic_launcher)
}
/**
* Cached progress notification to avoid creating a lot.
*/
val progressNotificationBuilder by lazy {
context.notificationBuilder(Notifications.CHANNEL_LIBRARY) {
setContentTitle(context.getString(R.string.app_name))
setSmallIcon(R.drawable.ic_refresh_24dp)
setLargeIcon(notificationBitmap)
setOngoing(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(manga: Manga, current: Int, total: Int) {
val title = manga.title
context.notificationManager.notify(
Notifications.ID_LIBRARY_PROGRESS,
progressNotificationBuilder
.setContentTitle(title)
.setProgress(total, current, false)
.build()
)
}
/**
* Shows notification containing update entries that failed with action to open full log.
*
* @param errors List of entry titles that failed to update.
* @param uri Uri for error log file containing all titles that failed.
*/
fun showUpdateErrorNotification(errors: List<String>, uri: Uri) {
if (errors.isEmpty()) {
return
}
context.notificationManager.notify(
Notifications.ID_LIBRARY_ERROR,
context.notificationBuilder(Notifications.CHANNEL_LIBRARY) {
setContentTitle(context.resources.getQuantityString(R.plurals.notification_update_failed, errors.size, errors.size))
setStyle(
NotificationCompat.BigTextStyle().bigText(
errors.joinToString("\n") {
it.chop(TITLE_MAX_LEN)
}
)
)
setSmallIcon(R.drawable.ic_tachi)
addAction(
R.drawable.nnf_ic_file_folder,
context.getString(R.string.view_all_errors),
NotificationReceiver.openErrorLogPendingActivity(context, uri)
)
}
.build()
)
}
/**
* Shows the notification containing the result of the update done by the service.
*
* @param updates a list of manga with new updates.
*/
suspend fun showResultNotification(updates: Map<LibraryManga, Array<Chapter>>) {
val notifications = ArrayList<Pair<Notification, Int>>()
updates.forEach {
val manga = it.key
val chapters = it.value
val chapterNames = chapters.map { chapter -> chapter.name }
notifications.add(Pair(context.notification(Notifications.CHANNEL_NEW_CHAPTERS) {
setSmallIcon(R.drawable.ic_tachi)
try {
val request = GetRequest.Builder(context).data(manga)
.networkCachePolicy(CachePolicy.DISABLED)
.transformations(CircleCropTransformation()).size(width = ICON_SIZE, height = ICON_SIZE)
.build()
Coil.imageLoader(context)
.execute(request).drawable?.let { drawable ->
setLargeIcon((drawable as BitmapDrawable).bitmap)
}
} catch (e: Exception) { }
setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
setContentTitle(manga.title)
color = ContextCompat.getColor(context, R.color.colorAccent)
val chaptersNames = if (chapterNames.size > MAX_CHAPTERS) {
"${chapterNames.take(MAX_CHAPTERS - 1)
.joinToString(", ")}, " + context.resources.getQuantityString(
R.plurals.notification_and_n_more,
(chapterNames.size - (MAX_CHAPTERS - 1)),
(chapterNames.size - (MAX_CHAPTERS - 1))
)
} else chapterNames.joinToString(", ")
setContentText(chaptersNames)
setStyle(NotificationCompat.BigTextStyle().bigText(chaptersNames))
priority = NotificationCompat.PRIORITY_HIGH
setGroup(Notifications.GROUP_NEW_CHAPTERS)
setContentIntent(
NotificationReceiver.openChapterPendingActivity(
context, manga, chapters.first()
)
)
addAction(
R.drawable.ic_glasses_black_24dp, context.getString(R.string.mark_as_read),
NotificationReceiver.markAsReadPendingBroadcast(
context,
manga, chapters, Notifications.ID_NEW_CHAPTERS
)
)
addAction(
R.drawable.ic_book_white_24dp, context.getString(R.string.view_chapters),
NotificationReceiver.openChapterPendingActivity(
context,
manga, Notifications.ID_NEW_CHAPTERS
)
)
setAutoCancel(true)
}, manga.id.hashCode()))
}
NotificationManagerCompat.from(context).apply {
notify(
Notifications.ID_NEW_CHAPTERS,
context.notification(Notifications.CHANNEL_NEW_CHAPTERS) {
setSmallIcon(R.drawable.ic_tachi)
setLargeIcon(notificationBitmap)
setContentTitle(context.getString(R.string.new_chapters_found))
color = ContextCompat.getColor(context, R.color.colorAccent)
if (updates.size > 1) {
setContentText(
context.resources.getQuantityString(
R.plurals
.for_n_titles,
updates.size, updates.size
)
)
setStyle(
NotificationCompat.BigTextStyle()
.bigText(updates.keys.joinToString("\n") {
it.title.chop(45)
})
)
} else {
setContentText(updates.keys.first().title.chop(45))
}
priority = NotificationCompat.PRIORITY_HIGH
setGroup(Notifications.GROUP_NEW_CHAPTERS)
setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_SUMMARY)
setGroupSummary(true)
setContentIntent(getNotificationIntent())
setAutoCancel(true)
})
notifications.forEach {
notify(it.second, it.first)
}
}
}
/**
* Cancels the progress notification.
*/
fun cancelProgressNotification() {
context.notificationManager.cancel(Notifications.ID_LIBRARY_PROGRESS)
}
/**
* Returns an intent to open the main activity.
*/
private fun getNotificationIntent(): PendingIntent {
val intent = Intent(context, MainActivity::class.java).apply {
flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
action = MainActivity.SHORTCUT_RECENTLY_UPDATED
}
return PendingIntent.getActivity(context, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
}
companion object {
private const val MAX_CHAPTERS = 5
private const val TITLE_MAX_LEN = 45
private const val ICON_SIZE = 192
}
}

View File

@ -1,25 +1,14 @@
package eu.kanade.tachiyomi.data.library
import android.app.Notification
import android.app.PendingIntent
import android.app.Service
import android.content.Context
import android.content.Intent
import android.graphics.BitmapFactory
import android.graphics.drawable.BitmapDrawable
import android.os.Build
import android.os.IBinder
import android.os.PowerManager
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationCompat.GROUP_ALERT_SUMMARY
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import coil.Coil
import coil.request.CachePolicy
import coil.request.GetRequest
import coil.request.LoadRequest
import coil.transform.CircleCropTransformation
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.cache.CoverCache
import eu.kanade.tachiyomi.data.database.DatabaseHelper
import eu.kanade.tachiyomi.data.database.models.Category
@ -30,7 +19,6 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.data.download.DownloadService
import eu.kanade.tachiyomi.data.library.LibraryUpdateRanker.rankingScheme
import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
import eu.kanade.tachiyomi.data.notification.NotificationReceiver
import eu.kanade.tachiyomi.data.notification.Notifications
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
import eu.kanade.tachiyomi.data.preference.getOrDefault
@ -39,12 +27,9 @@ import eu.kanade.tachiyomi.source.SourceManager
import eu.kanade.tachiyomi.source.fetchMangaDetailsAsync
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.ui.main.MainActivity
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
import eu.kanade.tachiyomi.util.lang.chop
import eu.kanade.tachiyomi.util.storage.getUriCompat
import eu.kanade.tachiyomi.util.system.executeOnIO
import eu.kanade.tachiyomi.util.system.notification
import eu.kanade.tachiyomi.util.system.notificationManager
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
@ -60,7 +45,7 @@ import kotlinx.coroutines.withContext
import timber.log.Timber
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import java.util.ArrayList
import java.io.File
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
@ -86,19 +71,7 @@ class LibraryUpdateService(
*/
private lateinit var wakeLock: PowerManager.WakeLock
/**
* Pending intent of action that cancels the library update
*/
private val cancelIntent by lazy {
NotificationReceiver.cancelLibraryUpdatePendingBroadcast(this)
}
/**
* Bitmap of the app for notifications.
*/
private val notificationBitmap by lazy {
BitmapFactory.decodeResource(resources, R.mipmap.ic_launcher)
}
private lateinit var notifier: LibraryUpdateNotifier
private var job: Job? = null
@ -111,6 +84,9 @@ class LibraryUpdateService(
// List containing new updates
private val newUpdates = mutableMapOf<LibraryManga, Array<Chapter>>()
// List containing failed updates
private val failedUpdates = mutableMapOf<Manga, String?>()
val count = AtomicInteger(0)
val jobCount = AtomicInteger(0)
@ -131,19 +107,6 @@ class LibraryUpdateService(
preferences.deleteRemovedChapters().get() != 1
}
/**
* Cached progress notification to avoid creating a lot.
*/
private val progressNotification by lazy {
NotificationCompat.Builder(this, Notifications.CHANNEL_LIBRARY)
.setContentTitle(getString(R.string.app_name))
.setSmallIcon(R.drawable.ic_refresh_white_24dp_img).setLargeIcon(notificationBitmap)
.setOngoing(true).setOnlyAlertOnce(true)
.setColor(ContextCompat.getColor(this, R.color.colorAccent)).addAction(
R.drawable.ic_clear_grey_24dp_img, getString(android.R.string.cancel), cancelIntent
)
}
/**
* Defines what should be updated within a service execution.
*/
@ -315,11 +278,12 @@ class LibraryUpdateService(
*/
override fun onCreate() {
super.onCreate()
startForeground(Notifications.ID_LIBRARY_PROGRESS, progressNotification.build())
notifier = LibraryUpdateNotifier(this)
wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager).newWakeLock(
PowerManager.PARTIAL_WAKE_LOCK, "LibraryUpdateService:WakeLock"
)
wakeLock.acquire(TimeUnit.MINUTES.toMillis(30))
startForeground(Notifications.ID_LIBRARY_PROGRESS, notifier.progressNotificationBuilder.build())
}
/**
@ -407,11 +371,11 @@ class LibraryUpdateService(
private suspend fun finishUpdates() {
if (jobCount.get() != 0) return
if (newUpdates.isNotEmpty()) {
showResultNotification(newUpdates)
notifier.showResultNotification(newUpdates)
if (preferences.refreshCoversToo().getOrDefault() && job?.isCancelled == false) {
updateDetails(newUpdates.keys.toList())
cancelProgressNotification()
notifier.cancelProgressNotification()
if (downloadNew && hasDownloads) {
DownloadService.start(this)
}
@ -420,7 +384,15 @@ class LibraryUpdateService(
}
newUpdates.clear()
}
cancelProgressNotification()
if (preferences.showLibraryUpdateErrors() && failedUpdates.isNotEmpty()) {
val errorFile = writeErrorFile(failedUpdates)
notifier.showUpdateErrorNotification(
failedUpdates.map { it.key.title },
errorFile.getUriCompat(this)
)
}
failedUpdates.clear()
notifier.cancelProgressNotification()
}
private suspend fun updateMangaInSource(
@ -459,7 +431,7 @@ class LibraryUpdateService(
if (job?.isCancelled == true) {
return false
}
showProgressNotification(manga, progress, mangaToUpdate.size)
notifier.showProgressNotification(manga, progress, mangaToUpdate.size)
val source = sourceManager.get(manga.source) as? HttpSource ?: return false
val fetchedChapters = withContext(Dispatchers.IO) {
source.fetchChapterList(manga).toBlocking().single()
@ -489,6 +461,7 @@ class LibraryUpdateService(
return hasDownloads
} catch (e: Exception) {
if (e !is CancellationException) {
failedUpdates[manga] = e.message
Timber.e("Failed updating: ${manga.title}: $e")
}
return false
@ -496,14 +469,9 @@ class LibraryUpdateService(
}
private fun downloadChapters(manga: Manga, chapters: List<Chapter>) {
// we need to get the chapters from the db so we have chapter ids
val mangaChapters = db.getChapters(manga).executeAsBlocking()
val dbChapters = chapters.map {
mangaChapters.find { mangaChapter -> mangaChapter.url == it.url }!!
}
// We don't want to start downloading while the library is updating, because websites
// may don't like it and they could ban the user.
downloadManager.downloadChapters(manga, dbChapters, false)
downloadManager.downloadChapters(manga, chapters, false)
}
/**
@ -523,7 +491,11 @@ class LibraryUpdateService(
return@async
}
val source = sourceManager.get(manga.source) as? HttpSource ?: return@async
showProgressNotification(manga, count.andIncrement, mangaToUpdate.size)
notifier.showProgressNotification(
manga,
count.andIncrement,
mangaToUpdate.size
)
val networkManga = try {
source.fetchMangaDetailsAsync(manga)
@ -550,7 +522,7 @@ class LibraryUpdateService(
}
}
asyncList.awaitAll()
cancelProgressNotification()
notifier.cancelProgressNotification()
}
/**
@ -565,7 +537,7 @@ class LibraryUpdateService(
val loggedServices = trackManager.services.filter { it.isLogged }
mangaToUpdate.forEach { manga ->
showProgressNotification(manga, count++, mangaToUpdate.size)
notifier.showProgressNotification(manga, count++, mangaToUpdate.size)
val tracks = db.getTracks(manga).executeAsBlocking()
@ -581,143 +553,28 @@ class LibraryUpdateService(
}
}
}
cancelProgressNotification()
notifier.cancelProgressNotification()
}
/**
* 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.
* Writes basic file of update errors to cache dir.
*/
private fun showProgressNotification(manga: Manga, current: Int, total: Int) {
notificationManager.notify(
Notifications.ID_LIBRARY_PROGRESS, progressNotification
.setContentTitle(manga.title)
.setProgress(total, current, false)
.build()
)
}
private fun writeErrorFile(errors: Map<Manga, String?>): File {
try {
if (errors.isNotEmpty()) {
val destFile = File(externalCacheDir, "tachiyomi_update_errors.txt")
/**
* Shows the notification containing the result of the update done by the service.
*
* @param updates a list of manga with new updates.
*/
private suspend fun showResultNotification(updates: Map<LibraryManga, Array<Chapter>>) {
val notifications = ArrayList<Pair<Notification, Int>>()
updates.forEach {
val manga = it.key
val chapters = it.value
val chapterNames = chapters.map { chapter -> chapter.name }
notifications.add(Pair(notification(Notifications.CHANNEL_NEW_CHAPTERS) {
setSmallIcon(R.drawable.ic_tachi)
try {
val request = GetRequest.Builder(this@LibraryUpdateService).data(manga)
.networkCachePolicy(CachePolicy.DISABLED)
.transformations(CircleCropTransformation()).size(width = 256, height = 256)
.build()
Coil.imageLoader(this@LibraryUpdateService)
.execute(request).drawable?.let { drawable ->
setLargeIcon((drawable as BitmapDrawable).bitmap)
}
} catch (e: Exception) {
}
setGroupAlertBehavior(GROUP_ALERT_SUMMARY)
setContentTitle(manga.title)
color = ContextCompat.getColor(this@LibraryUpdateService, R.color.colorAccent)
val chaptersNames = if (chapterNames.size > 5) {
"${chapterNames.take(4).joinToString(", ")}, " +
resources.getQuantityString(
R.plurals.notification_and_n_more,
(chapterNames.size - 4), (chapterNames.size - 4)
)
} else chapterNames.joinToString(", ")
setContentText(chaptersNames)
setStyle(NotificationCompat.BigTextStyle().bigText(chaptersNames))
priority = NotificationCompat.PRIORITY_HIGH
setGroup(Notifications.GROUP_NEW_CHAPTERS)
setContentIntent(
NotificationReceiver.openChapterPendingActivity(
this@LibraryUpdateService, manga, chapters.first()
)
)
addAction(
R.drawable.ic_glasses_black_24dp, getString(R.string.mark_as_read),
NotificationReceiver.markAsReadPendingBroadcast(
this@LibraryUpdateService,
manga, chapters, Notifications.ID_NEW_CHAPTERS
)
)
addAction(
R.drawable.ic_book_white_24dp, getString(R.string.view_chapters),
NotificationReceiver.openChapterPendingActivity(
this@LibraryUpdateService,
manga, Notifications.ID_NEW_CHAPTERS
)
)
setAutoCancel(true)
}, manga.id.hashCode()))
}
NotificationManagerCompat.from(this).apply {
notify(
Notifications.ID_NEW_CHAPTERS,
notification(Notifications.CHANNEL_NEW_CHAPTERS) {
setSmallIcon(R.drawable.ic_tachi)
setLargeIcon(notificationBitmap)
setContentTitle(getString(R.string.new_chapters_found))
color = ContextCompat.getColor(applicationContext, R.color.colorAccent)
if (updates.size > 1) {
setContentText(
resources.getQuantityString(
R.plurals
.for_n_titles,
updates.size, updates.size
)
)
setStyle(
NotificationCompat.BigTextStyle()
.bigText(updates.keys.joinToString("\n") {
it.title.chop(45)
})
)
} else {
setContentText(updates.keys.first().title.chop(45))
destFile.bufferedWriter().use { out ->
errors.forEach { (manga, error) ->
out.write("${manga.title}: $error\n")
}
priority = NotificationCompat.PRIORITY_HIGH
setGroup(Notifications.GROUP_NEW_CHAPTERS)
setGroupAlertBehavior(GROUP_ALERT_SUMMARY)
setGroupSummary(true)
setContentIntent(getNotificationIntent())
setAutoCancel(true)
})
notifications.forEach {
notify(it.second, it.first)
}
return destFile
}
} catch (e: Exception) {
// Empty
}
}
/**
* Cancels the progress notification.
*/
private fun cancelProgressNotification() {
notificationManager.cancel(Notifications.ID_LIBRARY_PROGRESS)
}
/**
* Returns an intent to open the main activity.
*/
private fun getNotificationIntent(): PendingIntent {
val intent = Intent(this, MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP
intent.action = MainActivity.SHORTCUT_RECENTLY_UPDATED
return PendingIntent.getActivity(this, 0, intent, PendingIntent.FLAG_UPDATE_CURRENT)
return File("")
}
}

View File

@ -435,6 +435,22 @@ class NotificationReceiver : BroadcastReceiver() {
)
}
/**
* Returns [PendingIntent] that opens the error log file in an external viewer
*
* @param context context of application
* @param uri uri of error log file
* @return [PendingIntent]
*/
internal fun openErrorLogPendingActivity(context: Context, uri: Uri): PendingIntent {
val intent = Intent().apply {
action = Intent.ACTION_VIEW
setDataAndType(uri, "text/plain")
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
}
return PendingIntent.getActivity(context, 0, intent, 0)
}
/**
* Returns [PendingIntent] that opens the extensions controller,
*

View File

@ -24,6 +24,7 @@ object Notifications {
*/
const val CHANNEL_LIBRARY = "library_channel"
const val ID_LIBRARY_PROGRESS = -101
const val ID_LIBRARY_ERROR = -102
/**
* Notification channel and ids used by the downloader.

View File

@ -121,8 +121,6 @@ object PreferenceKeys {
const val uniformGrid = "uniform_grid"
const val libraryAsSingleList = "library_as_single_list"
const val lang = "app_language"
const val dateFormat = "app_date_format"
@ -149,6 +147,8 @@ object PreferenceKeys {
const val updateOnRefresh = "update_on_refresh"
const val showLibraryUpdateErrors = "show_library_update_errors"
const val alwaysShowChapterTransition = "always_show_chapter_transition"
@Deprecated("Use the preferences of the source")

View File

@ -278,6 +278,8 @@ class PreferencesHelper(val context: Context) {
fun onlySearchPinned() = flowPrefs.getBoolean(Keys.onlySearchPinned, true)
fun showLibraryUpdateErrors() = prefs.getBoolean(Keys.showLibraryUpdateErrors, false)
// Tutorial preferences
fun shownFilterTutorial() = flowPrefs.getBoolean("shown_filter_tutorial", false)

View File

@ -1,7 +1,6 @@
package eu.kanade.tachiyomi.network
import android.content.Context
import com.chuckerteam.chucker.api.ChuckerInterceptor
import okhttp3.Cache
import okhttp3.OkHttpClient
import java.io.File
@ -17,7 +16,7 @@ class NetworkHelper(context: Context) {
val client = OkHttpClient.Builder()
.cookieJar(cookieManager)
.cache(Cache(cacheDir, cacheSize))
.addInterceptor(ChuckerInterceptor(context))
// .addInterceptor(ChuckerInterceptor(context))
.build()
val cloudflareClient = client.newBuilder()

View File

@ -159,6 +159,12 @@ class SettingsLibraryController : SettingsController() {
summaryRes = R.string.auto_refresh_covers_summary
defaultValue = true
}
switchPreference {
key = Keys.showLibraryUpdateErrors
titleRes = R.string.show_notification_error
defaultValue = false
}
}
}
}

View File

@ -170,6 +170,10 @@
<item quantity="one">and %1$d more chapter</item>
<item quantity="other">and %1$d more chapters</item>
</plurals>
<plurals name="notification_update_failed">
<item quantity="one">1 update failed</item>
<item quantity="other">%1$d updates failed</item>
</plurals>
<!-- Library settings -->
<string name="library_update_frequency">Library update frequency</string>
@ -186,6 +190,7 @@
<string name="auto_refresh_covers">Automatically refresh covers</string>
<string name="auto_refresh_covers_summary">Refresh covers in library as well
when updating library</string>
<string name="show_notification_error">Show a notification for errors</string>
<!-- Recents -->
<string name="recents">Recents</string>