Downloader improvements

This commit is contained in:
Koitharu
2023-12-01 14:07:35 +02:00
parent ff05f3f79d
commit a7a9ee9d59
11 changed files with 158 additions and 61 deletions

View File

@@ -151,6 +151,10 @@ class RemoteMangaRepository(
return parser.configKeyDomain.presetValues.toList() return parser.configKeyDomain.presetValues.toList()
} }
fun isSlowdownEnabled(): Boolean {
return getConfig().isSlowdownEnabled
}
private fun getConfig() = parser.config as SourceSettings private fun getConfig() = parser.config as SourceSettings
private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> { private suspend fun <T> asyncSafe(block: suspend CoroutineScope.() -> T): SafeDeferred<T> {

View File

@@ -259,9 +259,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
} }
} }
val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
val isDownloadsWiFiOnly: Boolean val isDownloadsWiFiOnly: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false) get() = prefs.getBoolean(KEY_DOWNLOADS_WIFI, false)
@@ -495,7 +492,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_SHIKIMORI = "shikimori" const val KEY_SHIKIMORI = "shikimori"
const val KEY_ANILIST = "anilist" const val KEY_ANILIST = "anilist"
const val KEY_MAL = "mal" const val KEY_MAL = "mal"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
const val KEY_DOWNLOADS_WIFI = "downloads_wifi" const val KEY_DOWNLOADS_WIFI = "downloads_wifi"
const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible" const val KEY_ALL_FAVOURITES_VISIBLE = "all_favourites_visible"
const val KEY_DOH = "doh" const val KEY_DOH = "doh"

View File

@@ -11,6 +11,7 @@ import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.model.SortOrder
private const val KEY_SORT_ORDER = "sort_order" private const val KEY_SORT_ORDER = "sort_order"
private const val KEY_SLOWDOWN = "slowdown"
class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig { class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig {
@@ -20,6 +21,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig
get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java) get() = prefs.getEnumValue(KEY_SORT_ORDER, SortOrder::class.java)
set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) } set(value) = prefs.edit { putEnumValue(KEY_SORT_ORDER, value) }
val isSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_SLOWDOWN, false)
@Suppress("UNCHECKED_CAST") @Suppress("UNCHECKED_CAST")
override fun <T> get(key: ConfigKey<T>): T { override fun <T> get(key: ConfigKey<T>): T {
return when (key) { return when (key) {

View File

@@ -0,0 +1,28 @@
package org.koitharu.kotatsu.download.ui.worker
import kotlinx.coroutines.delay
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.model.MangaSource
class DownloadSlowdownDispatcher(
private val mangaRepositoryFactory: MangaRepository.Factory,
private val defaultDelay: Long,
) {
private val timeMap = HashMap<MangaSource, Long>()
suspend fun delay(source: MangaSource) {
val repo = mangaRepositoryFactory.create(source) as? RemoteMangaRepository ?: return
if (!repo.isSlowdownEnabled()) {
return
}
val lastRequest = synchronized(timeMap) {
val res = timeMap[source] ?: 0L
timeMap[source] = System.currentTimeMillis()
res
}
if (lastRequest != 0L) {
delay(lastRequest + defaultDelay - System.currentTimeMillis())
}
}
}

View File

@@ -28,6 +28,10 @@ import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.Request import okhttp3.Request
@@ -71,6 +75,7 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.io.File import java.io.File
import java.util.UUID import java.util.UUID
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicInteger
import javax.inject.Inject import javax.inject.Inject
@HiltWorker @HiltWorker
@@ -81,7 +86,6 @@ class DownloadWorker @AssistedInject constructor(
private val cache: PagesCache, private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val mangaDataRepository: MangaDataRepository, private val mangaDataRepository: MangaDataRepository,
private val settings: AppSettings,
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
@LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>, @LocalStorageChanges private val localStorageChanges: MutableSharedFlow<LocalManga?>,
notificationFactoryFactory: DownloadNotificationFactory.Factory, notificationFactoryFactory: DownloadNotificationFactory.Factory,
@@ -89,16 +93,15 @@ class DownloadWorker @AssistedInject constructor(
private val notificationFactory = notificationFactoryFactory.create(params.id) private val notificationFactory = notificationFactoryFactory.create(params.id)
private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager private val notificationManager = appContext.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
private val slowdownDispatcher = DownloadSlowdownDispatcher(mangaRepositoryFactory, SLOWDOWN_DELAY)
@Volatile @Volatile
private var lastPublishedState: DownloadState? = null private var lastPublishedState: DownloadState? = null
private val currentState: DownloadState private val currentState: DownloadState
get() = checkNotNull(lastPublishedState) get() = checkNotNull(lastPublishedState)
private val pausingHandle = PausingHandle()
private val timeLeftEstimator = TimeLeftEstimator() private val timeLeftEstimator = TimeLeftEstimator()
private val notificationThrottler = Throttler(400) private val notificationThrottler = Throttler(400)
private val pausingReceiver = PausingReceiver(params.id, pausingHandle)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
@@ -109,7 +112,9 @@ class DownloadWorker @AssistedInject constructor(
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() } val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters(manga) val downloadedIds = getDoneChapters(manga)
return try { return try {
downloadMangaImpl(manga, chaptersIds, downloadedIds) withContext(PausingHandle()) {
downloadMangaImpl(manga, chaptersIds, downloadedIds)
}
Result.success(currentState.toWorkData()) Result.success(currentState.toWorkData())
} catch (e: CancellationException) { } catch (e: CancellationException) {
withContext(NonCancellable) { withContext(NonCancellable) {
@@ -153,6 +158,7 @@ class DownloadWorker @AssistedInject constructor(
) { ) {
var manga = subject var manga = subject
val chaptersToSkip = excludedIds.toMutableSet() val chaptersToSkip = excludedIds.toMutableSet()
val pausingReceiver = PausingReceiver(id, PausingHandle.current())
withMangaLock(manga) { withMangaLock(manga) {
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
applicationContext, applicationContext,
@@ -180,39 +186,47 @@ class DownloadWorker @AssistedInject constructor(
} }
val chapters = getChapters(mangaDetails, includedIds) val chapters = getChapters(mangaDetails, includedIds)
for ((chapterIndex, chapter) in chapters.withIndex()) { for ((chapterIndex, chapter) in chapters.withIndex()) {
checkIsPaused()
if (chaptersToSkip.remove(chapter.id)) { if (chaptersToSkip.remove(chapter.id)) {
publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1)) publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
continue continue
} }
val pages = runFailsafe(pausingHandle) { val pages = runFailsafe {
repo.getPages(chapter) repo.getPages(chapter)
} }
for ((pageIndex, page) in pages.withIndex()) { val pageCounter = AtomicInteger(0)
runFailsafe(pausingHandle) { channelFlow {
val url = repo.getPageUrl(page) val semaphore = Semaphore(MAX_PAGES_PARALLELISM)
val file = cache.get(url) for ((pageIndex, page) in pages.withIndex()) {
?: downloadFile(url, destination, tempFileName, repo.source) checkIsPaused()
output.addPage( launch {
chapter = chapter, semaphore.withPermit {
file = file, runFailsafe {
pageNumber = pageIndex, val url = repo.getPageUrl(page)
ext = MimeTypeMap.getFileExtensionFromUrl(url), val file = cache.get(url)
) ?: downloadFile(url, destination, tempFileName, repo.source)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
}
send(pageIndex)
}
}
} }
}.collect {
publishState( publishState(
currentState.copy( currentState.copy(
totalChapters = chapters.size, totalChapters = chapters.size,
currentChapter = chapterIndex, currentChapter = chapterIndex,
totalPages = pages.size, totalPages = pages.size,
currentPage = pageIndex, currentPage = pageCounter.incrementAndGet(),
isIndeterminate = false, isIndeterminate = false,
eta = timeLeftEstimator.getEta(), eta = timeLeftEstimator.getEta(),
), ),
) )
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
} }
if (output.flushChapter(chapter)) { if (output.flushChapter(chapter)) {
runCatchingCancellable { runCatchingCancellable {
@@ -244,14 +258,9 @@ class DownloadWorker @AssistedInject constructor(
} }
private suspend fun <R> runFailsafe( private suspend fun <R> runFailsafe(
pausingHandle: PausingHandle,
block: suspend () -> R, block: suspend () -> R,
): R { ): R {
if (pausingHandle.isPaused) { checkIsPaused()
publishState(currentState.copy(isPaused = true, eta = -1L))
pausingHandle.awaitResumed()
publishState(currentState.copy(isPaused = false))
}
var countDown = MAX_FAILSAFE_ATTEMPTS var countDown = MAX_FAILSAFE_ATTEMPTS
failsafe@ while (true) { failsafe@ while (true) {
try { try {
@@ -266,9 +275,13 @@ class DownloadWorker @AssistedInject constructor(
), ),
) )
countDown = MAX_FAILSAFE_ATTEMPTS countDown = MAX_FAILSAFE_ATTEMPTS
val pausingHandle = PausingHandle.current()
pausingHandle.pause() pausingHandle.pause()
pausingHandle.awaitResumed() try {
publishState(currentState.copy(isPaused = false, error = null)) pausingHandle.awaitResumed()
} finally {
publishState(currentState.copy(isPaused = false, error = null))
}
} else { } else {
countDown-- countDown--
val retryDelay = if (e is TooManyRequestExceptions) { val retryDelay = if (e is TooManyRequestExceptions) {
@@ -282,6 +295,18 @@ class DownloadWorker @AssistedInject constructor(
} }
} }
private suspend fun checkIsPaused() {
val pausingHandle = PausingHandle.current()
if (pausingHandle.isPaused) {
publishState(currentState.copy(isPaused = true, eta = -1L))
try {
pausingHandle.awaitResumed()
} finally {
publishState(currentState.copy(isPaused = false))
}
}
}
private suspend fun downloadFile( private suspend fun downloadFile(
url: String, url: String,
destination: File, destination: File,
@@ -295,13 +320,19 @@ class DownloadWorker @AssistedInject constructor(
.cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE) .cacheControl(CommonHeaders.CACHE_CONTROL_NO_STORE)
.get() .get()
.build() .build()
slowdownDispatcher.delay(source)
val call = okHttp.newCall(request) val call = okHttp.newCall(request)
val file = File(destination, tempFileName) val file = File(destination, tempFileName)
val response = call.clone().await() try {
checkNotNull(response.body).use { body -> val response = call.clone().await()
file.sink(append = false).buffer().use { checkNotNull(response.body).use { body ->
it.writeAllCancellable(body.source()) file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
}
} }
} catch (e: CancellationException) {
file.delete()
throw e
} }
return file return file
} }
@@ -461,8 +492,9 @@ class DownloadWorker @AssistedInject constructor(
private companion object { private companion object {
const val MAX_FAILSAFE_ATTEMPTS = 2 const val MAX_FAILSAFE_ATTEMPTS = 2
const val MAX_PAGES_PARALLELISM = 4
const val DOWNLOAD_ERROR_DELAY = 500L const val DOWNLOAD_ERROR_DELAY = 500L
const val SLOWDOWN_DELAY = 100L const val SLOWDOWN_DELAY = 200L
const val MANGA_ID = "manga_id" const val MANGA_ID = "manga_id"
const val CHAPTERS_IDS = "chapters" const val CHAPTERS_IDS = "chapters"
const val TAG = "download" const val TAG = "download"

View File

@@ -1,11 +1,13 @@
package org.koitharu.kotatsu.download.ui.worker package org.koitharu.kotatsu.download.ui.worker
import androidx.annotation.AnyThread import androidx.annotation.AnyThread
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlin.coroutines.AbstractCoroutineContextElement
import kotlin.coroutines.CoroutineContext
class PausingHandle { class PausingHandle : AbstractCoroutineContextElement(PausingHandle) {
private val paused = MutableStateFlow(false) private val paused = MutableStateFlow(false)
@@ -15,7 +17,7 @@ class PausingHandle {
@AnyThread @AnyThread
suspend fun awaitResumed() { suspend fun awaitResumed() {
paused.filter { !it }.first() paused.first { !it }
} }
@AnyThread @AnyThread
@@ -27,4 +29,17 @@ class PausingHandle {
fun resume() { fun resume() {
paused.value = false paused.value = false
} }
suspend fun yield() {
if (paused.value) {
paused.first { !it }
}
}
companion object : CoroutineContext.Key<PausingHandle> {
suspend fun current() = checkNotNull(currentCoroutineContext()[this]) {
"PausingHandle not found in current context"
}
}
} }

View File

@@ -2,6 +2,8 @@ package org.koitharu.kotatsu.local.data.output
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
@@ -20,6 +22,7 @@ class LocalMangaDirOutput(
private val chaptersOutput = HashMap<MangaChapter, ZipOutput>() private val chaptersOutput = HashMap<MangaChapter, ZipOutput>()
private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText()) private val index = MangaIndex(File(rootFile, ENTRY_NAME_INDEX).takeIfReadable()?.readText())
private val mutex = Mutex()
init { init {
if (!manga.isLocal) { if (!manga.isLocal) {
@@ -29,7 +32,7 @@ class LocalMangaDirOutput(
override suspend fun mergeWithExisting() = Unit override suspend fun mergeWithExisting() = Unit
override suspend fun addCover(file: File, ext: String) { override suspend fun addCover(file: File, ext: String) = mutex.withLock {
val name = buildString { val name = buildString {
append("cover") append("cover")
if (ext.isNotEmpty() && ext.length <= 4) { if (ext.isNotEmpty() && ext.length <= 4) {
@@ -44,7 +47,7 @@ class LocalMangaDirOutput(
flushIndex() flushIndex()
} }
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) = mutex.withLock {
val output = chaptersOutput.getOrPut(chapter) { val output = chaptersOutput.getOrPut(chapter) {
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP)) ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
} }
@@ -61,14 +64,14 @@ class LocalMangaDirOutput(
index.addChapter(chapter, chapterFileName(chapter)) index.addChapter(chapter, chapterFileName(chapter))
} }
override suspend fun flushChapter(chapter: MangaChapter): Boolean { override suspend fun flushChapter(chapter: MangaChapter): Boolean = mutex.withLock {
val output = chaptersOutput.remove(chapter) ?: return false val output = chaptersOutput.remove(chapter) ?: return@withLock false
output.flushAndFinish() output.flushAndFinish()
flushIndex() flushIndex()
return true true
} }
override suspend fun finish() { override suspend fun finish() = mutex.withLock {
flushIndex() flushIndex()
for (output in chaptersOutput.values) { for (output in chaptersOutput.values) {
output.flushAndFinish() output.flushAndFinish()
@@ -76,7 +79,7 @@ class LocalMangaDirOutput(
chaptersOutput.clear() chaptersOutput.clear()
} }
override suspend fun cleanup() { override suspend fun cleanup() = mutex.withLock {
for (output in chaptersOutput.values) { for (output in chaptersOutput.values) {
output.file.deleteAwait() output.file.deleteAwait()
} }
@@ -88,7 +91,7 @@ class LocalMangaDirOutput(
} }
} }
suspend fun deleteChapter(chapterId: Long) { suspend fun deleteChapter(chapterId: Long) = mutex.withLock {
val chapter = checkNotNull(index.getMangaInfo()?.chapters) { val chapter = checkNotNull(index.getMangaInfo()?.chapters) {
"No chapters found" "No chapters found"
}.findById(chapterId) ?: error("Chapter not found") }.findById(chapterId) ?: error("Chapter not found")

View File

@@ -3,6 +3,8 @@ package org.koitharu.kotatsu.local.data.output
import androidx.annotation.WorkerThread import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.util.ext.readText
@@ -20,6 +22,7 @@ class LocalMangaZipOutput(
private val output = ZipOutput(File(rootFile.path + ".tmp")) private val output = ZipOutput(File(rootFile.path + ".tmp"))
private val index = MangaIndex(null) private val index = MangaIndex(null)
private val mutex = Mutex()
init { init {
if (!manga.isLocal) { if (!manga.isLocal) {
@@ -27,7 +30,7 @@ class LocalMangaZipOutput(
} }
} }
override suspend fun mergeWithExisting() { override suspend fun mergeWithExisting() = mutex.withLock {
if (rootFile.exists()) { if (rootFile.exists()) {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
mergeWith(rootFile) mergeWith(rootFile)
@@ -35,7 +38,7 @@ class LocalMangaZipOutput(
} }
} }
override suspend fun addCover(file: File, ext: String) { override suspend fun addCover(file: File, ext: String) = mutex.withLock {
val name = buildString { val name = buildString {
append(FILENAME_PATTERN.format(0, 0, 0)) append(FILENAME_PATTERN.format(0, 0, 0))
if (ext.isNotEmpty() && ext.length <= 4) { if (ext.isNotEmpty() && ext.length <= 4) {
@@ -49,7 +52,7 @@ class LocalMangaZipOutput(
index.setCoverEntry(name) index.setCoverEntry(name)
} }
override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) { override suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) = mutex.withLock {
val name = buildString { val name = buildString {
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber)) append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) { if (ext.isNotEmpty() && ext.length <= 4) {
@@ -65,7 +68,7 @@ class LocalMangaZipOutput(
override suspend fun flushChapter(chapter: MangaChapter): Boolean = false override suspend fun flushChapter(chapter: MangaChapter): Boolean = false
override suspend fun finish() { override suspend fun finish() = mutex.withLock {
runInterruptible(Dispatchers.IO) { runInterruptible(Dispatchers.IO) {
output.put(ENTRY_NAME_INDEX, index.toString()) output.put(ENTRY_NAME_INDEX, index.toString())
output.finish() output.finish()
@@ -73,10 +76,12 @@ class LocalMangaZipOutput(
} }
rootFile.deleteAwait() rootFile.deleteAwait()
output.file.renameTo(rootFile) output.file.renameTo(rootFile)
Unit
} }
override suspend fun cleanup() { override suspend fun cleanup() = mutex.withLock {
output.file.deleteAwait() output.file.deleteAwait()
Unit
} }
override fun close() { override fun close() {

View File

@@ -533,4 +533,5 @@
<string name="error_multiple_genres_not_supported">Filtering by multiple genres is not supported by this manga source</string> <string name="error_multiple_genres_not_supported">Filtering by multiple genres is not supported by this manga source</string>
<string name="error_multiple_states_not_supported">Filtering by multiple states is not supported by this manga source</string> <string name="error_multiple_states_not_supported">Filtering by multiple states is not supported by this manga source</string>
<string name="error_search_not_supported">Search is not supported by this manga source</string> <string name="error_search_not_supported">Search is not supported by this manga source</string>
<string name="downloads_settings_info">You can enable download slowdown for each manga source individually in the source settings if you are having problems with server-side blocking</string>
</resources> </resources>

View File

@@ -17,13 +17,14 @@
android:defaultValue="false" android:defaultValue="false"
android:key="downloads_wifi" android:key="downloads_wifi"
android:summary="@string/downloads_wifi_only_summary" android:summary="@string/downloads_wifi_only_summary"
android:title="@string/downloads_wifi_only" android:title="@string/downloads_wifi_only" />
<Preference
android:icon="@drawable/ic_info_outline"
android:key="tracker_notifications_info"
android:persistent="false"
android:selectable="false"
android:summary="@string/downloads_settings_info"
app:allowDividerAbove="true" /> app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="downloads_slowdown"
android:summary="@string/download_slowdown_summary"
android:title="@string/download_slowdown" />
</PreferenceScreen> </PreferenceScreen>

View File

@@ -17,4 +17,12 @@
android:summary="@string/clear_source_cookies_summary" android:summary="@string/clear_source_cookies_summary"
android:title="@string/clear_cookies" /> android:title="@string/clear_cookies" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="slowdown"
android:order="105"
android:summary="@string/download_slowdown_summary"
android:title="@string/download_slowdown"
app:allowDividerAbove="true" />
</PreferenceScreen> </PreferenceScreen>