Merge remote-tracking branch 'origin/devel' into devel

pull/155/head
Xtimms 4 years ago
commit ca45774cdb

@ -14,8 +14,8 @@ android {
applicationId 'org.koitharu.kotatsu'
minSdkVersion 21
targetSdkVersion 32
versionCode 402
versionName '3.1.1'
versionCode 403
versionName '3.2'
generatedDensities = []
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
@ -65,7 +65,7 @@ android {
}
dependencies {
implementation fileTree(dir: 'libs', include: ['*.jar', '*.aar'])
implementation('com.github.nv95:kotatsu-parsers:c1aa8a45dc') {
implementation('com.github.nv95:kotatsu-parsers:1d171d41fe') {
exclude group: 'org.json', module: 'json'
}

@ -102,6 +102,7 @@
<service
android:name="org.koitharu.kotatsu.download.ui.service.DownloadService"
android:foregroundServiceType="dataSync" />
<service android:name="org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService" />
<service
android:name="org.koitharu.kotatsu.widget.shelf.ShelfWidgetService"
android:permission="android.permission.BIND_REMOTEVIEWS" />

@ -1,18 +1,18 @@
package org.koitharu.kotatsu.base.ui
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import kotlin.coroutines.CoroutineContext
import kotlin.coroutines.EmptyCoroutineContext
import kotlinx.coroutines.*
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.base.ui.util.CountedBooleanLiveData
import org.koitharu.kotatsu.utils.SingleLiveEvent
abstract class BaseViewModel : ViewModel() {
val onError = SingleLiveEvent<Throwable>()
val isLoading = MutableLiveData(false)
val isLoading = CountedBooleanLiveData()
protected fun launchJob(
context: CoroutineContext = EmptyCoroutineContext,

@ -0,0 +1,37 @@
package org.koitharu.kotatsu.base.ui
import android.app.Service
import android.content.Intent
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
abstract class CoroutineIntentService : BaseService() {
private val mutex = Mutex()
protected open val dispatcher: CoroutineDispatcher = Dispatchers.Default
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
super.onStartCommand(intent, flags, startId)
launchCoroutine(intent, startId)
return Service.START_REDELIVER_INTENT
}
private fun launchCoroutine(intent: Intent?, startId: Int) = lifecycleScope.launch {
mutex.withLock {
try {
withContext(dispatcher) {
processIntent(intent)
}
} finally {
stopSelf(startId)
}
}
}
protected abstract suspend fun processIntent(intent: Intent?)
}

@ -0,0 +1,20 @@
package org.koitharu.kotatsu.base.ui.util
import androidx.lifecycle.MutableLiveData
class CountedBooleanLiveData : MutableLiveData<Boolean>(false) {
private var counter = 0
override fun setValue(value: Boolean) {
if (value) {
counter++
} else {
counter--
}
val newValue = counter > 0
if (newValue != this.value) {
super.setValue(newValue)
}
}
}

@ -1,51 +0,0 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import java.io.File
import java.util.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.json.JSONArray
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.utils.MutableZipFile
import org.koitharu.kotatsu.utils.ext.format
class BackupArchive(file: File) : MutableZipFile(file) {
init {
if (!dir.exists()) {
dir.mkdirs()
}
}
suspend fun put(entry: BackupEntry) {
put(entry.name, entry.data.toString(2))
}
suspend fun getEntry(name: String): BackupEntry {
val json = withContext(Dispatchers.Default) {
JSONArray(getContent(name))
}
return BackupEntry(name, json)
}
companion object {
private const val DIR_BACKUPS = "backups"
suspend fun createNew(context: Context): BackupArchive = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
val filename = buildString {
append(context.getString(R.string.app_name).lowercase(Locale.ROOT))
append('_')
append(Date().format("ddMMyyyy"))
append(".bak")
}
BackupArchive(File(dir, filename))
}
}
}

@ -0,0 +1,25 @@
package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.json.JSONArray
import java.io.File
import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file)
suspend fun getEntry(name: String): BackupEntry = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name)
val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText())
}
BackupEntry(name, json)
}
override fun close() {
zipFile.close()
}
}

@ -0,0 +1,45 @@
package org.koitharu.kotatsu.core.backup
import android.content.Context
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.zip.ZipOutput
import org.koitharu.kotatsu.utils.ext.format
import java.io.File
import java.util.*
import java.util.zip.Deflater
class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) {
output.put(entry.name, entry.data.toString(2))
}
suspend fun finish() {
output.finish()
}
override fun close() {
output.close()
}
}
private const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}
dir.mkdirs()
val filename = buildString {
append(context.getString(R.string.app_name).replace(' ', '_').lowercase(Locale.ROOT))
append('_')
append(Date().format("ddMMyyyy"))
append(".bk.zip")
}
BackupZipOutput(File(dir, filename))
}

@ -151,6 +151,12 @@ class AppSettings(context: Context) {
}
}
val isDownloadsSlowdownEnabled: Boolean
get() = prefs.getBoolean(KEY_DOWNLOADS_SLOWDOWN, false)
val downloadsParallelism: Int
get() = prefs.getInt(KEY_DOWNLOADS_PARALLELISM, 2)
val isSuggestionsEnabled: Boolean
get() = prefs.getBoolean(KEY_SUGGESTIONS, false)
@ -270,6 +276,8 @@ class AppSettings(context: Context) {
const val KEY_SUGGESTIONS_EXCLUDE_NSFW = "suggestions_exclude_nsfw"
const val KEY_SUGGESTIONS_EXCLUDE_TAGS = "suggestions_exclude_tags"
const val KEY_SEARCH_SINGLE_SOURCE = "search_single_source"
const val KEY_DOWNLOADS_PARALLELISM = "downloads_parallelism"
const val KEY_DOWNLOADS_SLOWDOWN = "downloads_slowdown"
// About
const val KEY_APP_UPDATE = "app_update"

@ -0,0 +1,118 @@
package org.koitharu.kotatsu.core.zip
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import okio.Closeable
import java.io.File
import java.io.FileInputStream
import java.util.zip.Deflater
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import java.util.zip.ZipOutputStream
class ZipOutput(
val file: File,
compressionLevel: Int = Deflater.DEFAULT_COMPRESSION,
) : Closeable {
private val entryNames = ArraySet<String>()
private var isClosed = false
private val output = ZipOutputStream(file.outputStream()).apply {
setLevel(compressionLevel)
}
@WorkerThread
fun put(name: String, file: File): Boolean {
return output.appendFile(file, name)
}
@WorkerThread
fun put(name: String, content: String): Boolean {
return output.appendText(content, name)
}
@WorkerThread
fun addDirectory(name: String): Boolean {
val entry = if (name.endsWith("/")) {
ZipEntry(name)
} else {
ZipEntry("$name/")
}
return if (entryNames.add(entry.name)) {
output.putNextEntry(entry)
output.closeEntry()
true
} else {
false
}
}
@WorkerThread
fun copyEntryFrom(other: ZipFile, entry: ZipEntry): Boolean {
return if (entryNames.add(entry.name)) {
val zipEntry = ZipEntry(entry.name)
output.putNextEntry(zipEntry)
other.getInputStream(entry).use { input ->
input.copyTo(output)
}
output.closeEntry()
true
} else {
false
}
}
fun finish() {
output.finish()
output.flush()
}
override fun close() {
if (!isClosed) {
output.close()
isClosed = true
}
}
@WorkerThread
private fun ZipOutputStream.appendFile(fileToZip: File, name: String): Boolean {
if (fileToZip.isDirectory) {
val entry = if (name.endsWith("/")) {
ZipEntry(name)
} else {
ZipEntry("$name/")
}
if (!entryNames.add(entry.name)) {
return false
}
putNextEntry(entry)
closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
appendFile(childFile, "$name/${childFile.name}")
}
} else {
FileInputStream(fileToZip).use { fis ->
if (!entryNames.add(name)) {
return false
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
fis.copyTo(this)
closeEntry()
}
}
return true
}
@WorkerThread
private fun ZipOutputStream.appendText(content: String, name: String): Boolean {
if (!entryNames.add(name)) {
return false
}
val zipEntry = ZipEntry(name)
putNextEntry(zipEntry)
content.byteInputStream().copyTo(this)
closeEntry()
return true
}
}

@ -9,9 +9,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.appcompat.view.ActionMode
import androidx.appcompat.widget.SearchView
import androidx.core.graphics.Insets
import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.core.view.updatePadding
import com.google.android.material.snackbar.Snackbar
import org.koin.androidx.viewmodel.ext.android.sharedViewModel
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.BaseFragment
@ -22,6 +22,7 @@ import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
@ -67,8 +68,8 @@ class ChaptersFragment :
viewModel.isChaptersReversed.observe(viewLifecycleOwner) {
activity?.invalidateOptionsMenu()
}
viewModel.hasChapters.observe(viewLifecycleOwner) {
binding.textViewHolder.isGone = it
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
activity?.invalidateOptionsMenu()
}
}
@ -94,7 +95,7 @@ class ChaptersFragment :
override fun onPrepareOptionsMenu(menu: Menu) {
super.onPrepareOptionsMenu(menu)
menu.findItem(R.id.action_reversed).isChecked = viewModel.isChaptersReversed.value == true
menu.findItem(R.id.action_search).isVisible = viewModel.hasChapters.value == true
menu.findItem(R.id.action_search).isVisible = viewModel.isChaptersEmpty.value == false
}
override fun onOptionsItemSelected(item: MenuItem) = when (item.itemId) {
@ -154,11 +155,29 @@ class ChaptersFragment :
DownloadService.start(
context ?: return false,
viewModel.getRemoteManga() ?: viewModel.manga.value ?: return false,
selectionDecoration?.checkedItemsIds
selectionDecoration?.checkedItemsIds?.toSet()
)
mode.finish()
true
}
R.id.action_delete -> {
val ids = selectionDecoration?.checkedItemsIds
val manga = viewModel.manga.value
when {
ids.isNullOrEmpty() || manga == null -> Unit
ids.size == manga.chapters?.size -> viewModel.deleteLocal()
else -> {
LocalChaptersRemoveService.start(requireContext(), manga, ids)
Snackbar.make(
binding.recyclerViewChapters,
R.string.chapters_will_removed_background,
Snackbar.LENGTH_LONG
).show()
}
}
mode.finish()
true
}
R.id.action_select_all -> {
val ids = chaptersAdapter?.items?.map { it.chapter.id } ?: return false
selectionDecoration?.checkAll(ids)
@ -188,6 +207,9 @@ class ChaptersFragment :
menu.findItem(R.id.action_save).isVisible = items.none { x ->
x.chapter.source == MangaSource.LOCAL
}
menu.findItem(R.id.action_delete).isVisible = items.all { x ->
x.chapter.source == MangaSource.LOCAL
}
mode.title = items.size.toString()
return true
}

@ -41,13 +41,16 @@ import org.koitharu.kotatsu.details.ui.adapter.BranchesAdapter
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.mapNotNullToSet
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.search.ui.global.GlobalSearchActivity
import org.koitharu.kotatsu.utils.ShareHelper
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediator.TabConfigurationStrategy,
class DetailsActivity :
BaseActivity<ActivityDetailsBinding>(),
TabLayoutMediator.TabConfigurationStrategy,
AdapterView.OnItemSelectedListener {
private val viewModel by viewModel<DetailsViewModel> {
@ -171,38 +174,23 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
true
}
R.id.action_delete -> {
viewModel.manga.value?.let { m ->
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, m.title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal(m)
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
val title = viewModel.manga.value?.title.orEmpty()
MaterialAlertDialogBuilder(this)
.setTitle(R.string.delete_manga)
.setMessage(getString(R.string.text_delete_local_manga, title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
true
}
R.id.action_save -> {
viewModel.manga.value?.let {
val chaptersCount = it.chapters?.size ?: 0
if (chaptersCount > 5) {
MaterialAlertDialogBuilder(this)
.setTitle(R.string.save_manga)
.setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(
R.plurals.chapters,
chaptersCount,
chaptersCount
)
)
)
.setNegativeButton(android.R.string.cancel, null)
.setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, it)
}.show()
val branches = viewModel.branches.value.orEmpty()
if (chaptersCount > 5 || branches.size > 1) {
showSaveConfirmation(it, chaptersCount, branches)
} else {
DownloadService.start(this, it)
}
@ -262,7 +250,7 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
fun showChapterMissingDialog(chapterId: Long) {
val remoteManga = viewModel.getRemoteManga()
if (remoteManga == null) {
binding.snackbar.show(getString( R.string.chapter_is_missing))
binding.snackbar.show(getString(R.string.chapter_is_missing))
return
}
MaterialAlertDialogBuilder(this).apply {
@ -328,6 +316,36 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
}
}
private fun showSaveConfirmation(manga: Manga, chaptersCount: Int, branches: List<String?>) {
val dialogBuilder = MaterialAlertDialogBuilder(this)
.setTitle(R.string.save_manga)
.setNegativeButton(android.R.string.cancel, null)
if (branches.size > 1) {
val items = Array(branches.size) { i -> branches[i].orEmpty() }
val currentBranch = viewModel.selectedBranchIndex.value ?: -1
val checkedIndices = BooleanArray(branches.size) { i -> i == currentBranch }
dialogBuilder.setMultiChoiceItems(items, checkedIndices) { _, i, checked ->
checkedIndices[i] = checked
}.setPositiveButton(R.string.save) { _, _ ->
val selectedBranches = branches.filterIndexedTo(HashSet()) { i, _ -> checkedIndices[i] }
val chaptersIds = manga.chapters?.mapNotNullToSet { c ->
if (c.branch in selectedBranches) c.id else null
}
DownloadService.start(this, manga, chaptersIds)
}
} else {
dialogBuilder.setMessage(
getString(
R.string.large_manga_save_confirm,
resources.getQuantityString(R.plurals.chapters, chaptersCount, chaptersCount)
)
).setPositiveButton(R.string.save) { _, _ ->
DownloadService.start(this, manga)
}
}
dialogBuilder.show()
}
companion object {
fun newIntent(context: Context, manga: Manga): Intent {
@ -340,4 +358,4 @@ class DetailsActivity : BaseActivity<ActivityDetailsBinding>(), TabLayoutMediato
.putExtra(MangaIntent.KEY_ID, mangaId)
}
}
}
}

@ -28,6 +28,7 @@ import org.koitharu.kotatsu.parsers.util.mapToSet
import org.koitharu.kotatsu.parsers.util.toTitleCase
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.ext.iterator
import java.io.IOException
@ -88,18 +89,18 @@ class DetailsViewModel(
val branches = mangaData.map {
it?.chapters?.mapToSet { x -> x.branch }?.sortedBy { x -> x }.orEmpty()
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val selectedBranchIndex = combine(
branches.asFlow(),
selectedBranch
) { branches, selected ->
branches.indexOf(selected)
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default)
val hasChapters = mangaData.map {
!(it?.chapters.isNullOrEmpty())
}.asLiveData(viewModelScope.coroutineContext + Dispatchers.Default)
val isChaptersEmpty = mangaData.mapNotNull { m ->
m?.run { chapters.isNullOrEmpty() }
}.asLiveDataDistinct(viewModelScope.coroutineContext + Dispatchers.Default, false)
val chapters = combine(
combine(
@ -134,8 +135,11 @@ class DetailsViewModel(
loadingJob = doLoad()
}
fun deleteLocal(manga: Manga) {
fun deleteLocal() {
val m = mangaData.value ?: return
launchLoadingJob(Dispatchers.Default) {
val manga = if (m.source == MangaSource.LOCAL) m else localMangaRepository.findSavedManga(m)
checkNotNull(manga) { "Cannot find saved manga for ${m.title}" }
val original = localMangaRepository.getRemoteManga(manga)
localMangaRepository.delete(manga) || throw IOException("Unable to delete file")
runCatching {
@ -252,10 +256,10 @@ class DetailsViewModel(
val dateFormat = settings.getDateFormat()
for (i in sourceChapters.indices) {
val chapter = sourceChapters[i]
val localChapter = chaptersMap.remove(chapter.id)
if (chapter.branch != branch) {
continue
}
val localChapter = chaptersMap.remove(chapter.id)
result += localChapter?.toListItem(
isCurrent = i == currentIndex,
isUnread = i > currentIndex,
@ -274,15 +278,19 @@ class DetailsViewModel(
}
if (chaptersMap.isNotEmpty()) { // some chapters on device but not online source
result.ensureCapacity(result.size + chaptersMap.size)
chaptersMap.values.mapTo(result) {
it.toListItem(
isCurrent = false,
isUnread = true,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
chaptersMap.values.mapNotNullTo(result) {
if (it.branch == branch) {
it.toListItem(
isCurrent = false,
isUnread = true,
isNew = false,
isMissing = false,
isDownloaded = false,
dateFormat = dateFormat,
)
} else {
null
}
}
result.sortBy { it.chapter.number }
}

@ -40,11 +40,10 @@ class ChapterListItem(
override fun hashCode(): Int {
var result = chapter.hashCode()
result = 31 * result + flags
result = 31 * result + uploadDate.hashCode()
result = 31 * result + (uploadDate?.hashCode() ?: 0)
return result
}
companion object {
const val FLAG_UNREAD = 2

@ -7,7 +7,6 @@ import android.webkit.MimeTypeMap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.size.Scale
import java.io.File
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.sync.Semaphore
@ -18,8 +17,9 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.local.domain.CbzMangaOutput
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaSource
@ -28,11 +28,11 @@ import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.referer
import org.koitharu.kotatsu.utils.ext.waitForNetwork
import org.koitharu.kotatsu.utils.progress.ProgressJob
import java.io.File
private const val MAX_DOWNLOAD_ATTEMPTS = 3
private const val MAX_PARALLEL_DOWNLOADS = 2
private const val DOWNLOAD_ERROR_DELAY = 500L
private const val TEMP_PAGE_FILE = "page.tmp"
private const val SLOWDOWN_DELAY = 200L
class DownloadManager(
private val coroutineScope: CoroutineScope,
@ -41,9 +41,10 @@ class DownloadManager(
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
) {
private val connectivityManager = context.applicationContext.getSystemService(
private val connectivityManager = context.getSystemService(
Context.CONNECTIVITY_SERVICE
) as ConnectivityManager
private val coverWidth = context.resources.getDimensionPixelSize(
@ -52,7 +53,7 @@ class DownloadManager(
private val coverHeight = context.resources.getDimensionPixelSize(
androidx.core.R.dimen.compat_notification_large_icon_max_height
)
private val semaphore = Semaphore(MAX_PARALLEL_DOWNLOADS)
private val semaphore = Semaphore(settings.downloadsParallelism)
fun downloadManga(
manga: Manga,
@ -80,7 +81,8 @@ class DownloadManager(
var cover: Drawable? = null
val destination = localMangaRepository.getOutputDir()
checkNotNull(destination) { context.getString(R.string.cannot_find_available_storage) }
var output: MangaZip? = null
val tempFileName = "${manga.id}_$startId.tmp"
var output: CbzMangaOutput? = null
try {
if (manga.source == MangaSource.LOCAL) {
manga = localMangaRepository.getRemoteManga(manga) ?: error("Cannot obtain remote manga instance")
@ -98,10 +100,9 @@ class DownloadManager(
}.getOrNull()
outState.value = DownloadState.Preparing(startId, manga, cover)
val data = if (manga.chapters.isNullOrEmpty()) repo.getDetails(manga) else manga
output = MangaZip.findInDir(destination, data)
output.prepare(data)
output = CbzMangaOutput.get(destination, data)
val coverUrl = data.largeCoverUrl ?: data.coverUrl
downloadFile(coverUrl, data.publicUrl, destination).let { file ->
downloadFile(coverUrl, data.publicUrl, destination, tempFileName).let { file ->
output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
}
val chapters = checkNotNull(
@ -118,22 +119,29 @@ class DownloadManager(
for ((chapterIndex, chapter) in chapters.withIndex()) {
val pages = repo.getPages(chapter)
for ((pageIndex, page) in pages.withIndex()) {
failsafe@ do {
var retryCounter = 0
failsafe@ while (true) {
try {
val url = repo.getPageUrl(page)
val file = cache[url] ?: downloadFile(url, page.referer, destination)
val file = cache[url] ?: downloadFile(url, page.referer, destination, tempFileName)
output.addPage(
chapter = chapter,
file = file,
pageNumber = pageIndex,
ext = MimeTypeMap.getFileExtensionFromUrl(url),
)
break@failsafe
} catch (e: IOException) {
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
connectivityManager.waitForNetwork()
continue@failsafe
if (retryCounter < MAX_DOWNLOAD_ATTEMPTS) {
outState.value = DownloadState.WaitingForNetwork(startId, data, cover)
delay(DOWNLOAD_ERROR_DELAY)
connectivityManager.waitForNetwork()
retryCounter++
} else {
throw e
}
}
} while (false)
}
outState.value = DownloadState.Progress(
startId, data, cover,
@ -142,12 +150,15 @@ class DownloadManager(
totalPages = pages.size,
currentPage = pageIndex,
)
if (settings.isDownloadsSlowdownEnabled) {
delay(SLOWDOWN_DELAY)
}
}
}
outState.value = DownloadState.PostProcessing(startId, data, cover)
if (!output.compress()) {
throw RuntimeException("Cannot create target file")
}
output.mergeWithExisting()
output.finalize()
val localManga = localMangaRepository.getFromFile(output.file)
outState.value = DownloadState.Done(startId, data, cover, localManga)
} catch (e: CancellationException) {
@ -161,14 +172,14 @@ class DownloadManager(
} finally {
withContext(NonCancellable) {
output?.cleanup()
File(destination, TEMP_PAGE_FILE).deleteAwait()
File(destination, tempFileName).deleteAwait()
}
coroutineContext[WakeLockNode]?.release()
semaphore.release()
}
}
private suspend fun downloadFile(url: String, referer: String, destination: File): File {
private suspend fun downloadFile(url: String, referer: String, destination: File, tempFileName: String): File {
val request = Request.Builder()
.url(url)
.header(CommonHeaders.REFERER, referer)
@ -176,26 +187,14 @@ class DownloadManager(
.get()
.build()
val call = okHttp.newCall(request)
var attempts = MAX_DOWNLOAD_ATTEMPTS
val file = File(destination, TEMP_PAGE_FILE)
while (true) {
try {
val response = call.clone().await()
runInterruptible(Dispatchers.IO) {
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out)
}
}
return file
} catch (e: IOException) {
attempts--
if (attempts <= 0) {
throw e
} else {
delay(DOWNLOAD_ERROR_DELAY)
}
val file = File(destination, tempFileName)
val response = call.clone().await()
runInterruptible(Dispatchers.IO) {
file.outputStream().use { out ->
checkNotNull(response.body).byteStream().copyTo(out)
}
}
return file
}
private fun errorStateHandler(outState: MutableStateFlow<DownloadState>) =
@ -208,4 +207,24 @@ class DownloadManager(
error = throwable,
)
}
class Factory(
private val context: Context,
private val imageLoader: ImageLoader,
private val okHttp: OkHttpClient,
private val cache: PagesCache,
private val localMangaRepository: LocalMangaRepository,
private val settings: AppSettings,
) {
fun create(coroutineScope: CoroutineScope) = DownloadManager(
coroutineScope = coroutineScope,
context = context,
imageLoader = imageLoader,
okHttp = okHttp,
cache = cache,
localMangaRepository = localMangaRepository,
settings = settings,
)
}
}

@ -6,6 +6,7 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.os.Build
import android.text.format.DateUtils
import androidx.core.app.NotificationCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
@ -49,7 +50,7 @@ class DownloadNotification(private val context: Context, startId: Int) {
builder.setSilent(true)
}
fun create(state: DownloadState): Notification {
fun create(state: DownloadState, timeLeft: Long): Notification {
builder.setContentTitle(state.manga.title)
builder.setContentText(context.getString(R.string.manga_downloading_))
builder.setProgress(1, 0, true)
@ -117,7 +118,13 @@ class DownloadNotification(private val context: Context, startId: Int) {
}
is DownloadState.Progress -> {
builder.setProgress(state.max, state.progress, false)
builder.setContentText((state.percent * 100).format() + "%")
if (timeLeft > 0L) {
val eta = DateUtils.getRelativeTimeSpanString(timeLeft, 0L, DateUtils.SECOND_IN_MILLIS)
builder.setContentText(eta)
} else {
val percent = (state.percent * 100).format()
builder.setContentText(context.getString(R.string.percent_string_pattern, percent))
}
builder.setCategory(NotificationCompat.CATEGORY_PROGRESS)
builder.setStyle(null)
builder.setOngoing(true)

@ -11,10 +11,7 @@ import android.widget.Toast
import androidx.core.content.ContextCompat
import androidx.lifecycle.lifecycleScope
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.flow.transformWhile
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.plus
import org.koin.android.ext.android.get
@ -32,6 +29,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.ext.connectivityManager
import org.koitharu.kotatsu.utils.ext.throttle
import org.koitharu.kotatsu.utils.progress.ProgressJob
import org.koitharu.kotatsu.utils.progress.TimeLeftEstimator
import java.util.concurrent.TimeUnit
class DownloadService : BaseService() {
@ -46,16 +44,12 @@ class DownloadService : BaseService() {
override fun onCreate() {
super.onCreate()
isRunning = true
notificationSwitcher = ForegroundNotificationSwitcher(this)
val wakeLock = (getSystemService(Context.POWER_SERVICE) as PowerManager)
.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "kotatsu:downloading")
downloadManager = DownloadManager(
downloadManager = get<DownloadManager.Factory>().create(
coroutineScope = lifecycleScope + WakeLockNode(wakeLock, TimeUnit.HOURS.toMillis(1)),
context = this,
imageLoader = get(),
okHttp = get(),
cache = get(),
localMangaRepository = get(),
)
DownloadNotification.createChannel(this)
registerReceiver(controlReceiver, IntentFilter(ACTION_DOWNLOAD_CANCEL))
@ -88,6 +82,7 @@ class DownloadService : BaseService() {
override fun onDestroy() {
unregisterReceiver(controlReceiver)
binder = null
isRunning = false
super.onDestroy()
}
@ -104,13 +99,22 @@ class DownloadService : BaseService() {
private fun listenJob(job: ProgressJob<DownloadState>) {
lifecycleScope.launch {
val startId = job.progressValue.startId
val timeLeftEstimator = TimeLeftEstimator()
val notification = DownloadNotification(this@DownloadService, startId)
notificationSwitcher.notify(startId, notification.create(job.progressValue))
notificationSwitcher.notify(startId, notification.create(job.progressValue, -1L))
job.progressAsFlow()
.onEach { state ->
if (state is DownloadState.Progress) {
timeLeftEstimator.tick(value = state.progress, total = state.max)
} else {
timeLeftEstimator.emptyTick()
}
}
.throttle { state -> if (state is DownloadState.Progress) 400L else 0L }
.whileActive()
.collect { state ->
notificationSwitcher.notify(startId, notification.create(state))
val timeLeft = timeLeftEstimator.getEstimatedTimeLeft()
notificationSwitcher.notify(startId, notification.create(state, timeLeft))
}
job.join()
(job.progressValue as? DownloadState.Done)?.let {
@ -124,7 +128,7 @@ class DownloadService : BaseService() {
if (job.isCancelled) {
null
} else {
notification.create(job.progressValue)
notification.create(job.progressValue, -1L)
}
)
stopSelf(startId)
@ -160,11 +164,12 @@ class DownloadService : BaseService() {
companion object {
const val ACTION_DOWNLOAD_COMPLETE =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
var isRunning: Boolean = false
private set
const val ACTION_DOWNLOAD_COMPLETE = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_COMPLETE"
private const val ACTION_DOWNLOAD_CANCEL =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val ACTION_DOWNLOAD_CANCEL = "${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"

@ -25,6 +25,7 @@ import org.koitharu.kotatsu.main.ui.AppBarOwner
import org.koitharu.kotatsu.parsers.model.SortOrder
import org.koitharu.kotatsu.utils.ext.getDisplayMessage
import org.koitharu.kotatsu.utils.ext.measureHeight
import org.koitharu.kotatsu.utils.ext.resolveDp
import java.util.*
class FavouritesContainerFragment :
@ -85,7 +86,7 @@ class FavouritesContainerFragment :
top = headerHeight - insets.top
)
binding.pager.updatePadding(
top = -headerHeight
top = -headerHeight + resources.resolveDp(8) // 8 dp is needed so that the top of the list is not attached to tabs (visible when ActionMode is active)
)
binding.tabs.apply {
updatePadding(

@ -20,6 +20,7 @@ import org.koitharu.kotatsu.base.ui.list.FitHeightGridLayoutManager
import org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager
import org.koitharu.kotatsu.base.ui.list.PaginationScrollListener
import org.koitharu.kotatsu.base.ui.list.decor.SpacingItemDecoration
import org.koitharu.kotatsu.base.ui.list.decor.TypedSpacingItemDecoration
import org.koitharu.kotatsu.browser.cloudflare.CloudFlareDialog
import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
@ -243,8 +244,11 @@ abstract class MangaListFragment :
ListMode.LIST -> {
layoutManager = FitHeightLinearLayoutManager(context)
val spacing = resources.getDimensionPixelOffset(R.dimen.list_spacing)
addItemDecoration(SpacingItemDecoration(spacing))
updatePadding(left = spacing, right = spacing)
val decoration = TypedSpacingItemDecoration(
MangaListAdapter.ITEM_TYPE_MANGA_LIST to 0,
fallbackSpacing = spacing
)
addItemDecoration(decoration)
}
ListMode.DETAILED_LIST -> {
layoutManager = FitHeightLinearLayoutManager(context)

@ -28,6 +28,7 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
ColorUtils.blendARGB(strokeColor, context.getThemeColor(materialR.attr.colorSurface), 0.8f),
0x74
)
private val defaultRadius = context.resources.getDimension(R.dimen.list_selector_corner)
init {
hasBackground = false
@ -51,21 +52,24 @@ class MangaSelectionDecoration(context: Context) : AbstractSelectionItemDecorati
bounds: RectF,
state: RecyclerView.State,
) {
val radius = (child as? CardView)?.radius ?: 0f
val isCard = child is CardView
val radius = (child as? CardView)?.radius ?: defaultRadius
paint.color = fillColor
paint.style = Paint.Style.FILL
canvas.drawRoundRect(bounds, radius, radius, paint)
paint.color = strokeColor
paint.style = Paint.Style.STROKE
canvas.drawRoundRect(bounds, radius, radius, paint)
checkIcon?.run {
setBounds(
(bounds.left + iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.left + iconOffset + intrinsicWidth).toInt(),
(bounds.top + iconOffset + intrinsicHeight).toInt(),
)
draw(canvas)
if (isCard) {
checkIcon?.run {
setBounds(
(bounds.left + iconOffset).toInt(),
(bounds.top + iconOffset).toInt(),
(bounds.left + iconOffset + intrinsicWidth).toInt(),
(bounds.top + iconOffset + intrinsicHeight).toInt(),
)
draw(canvas)
}
}
}
}

@ -3,6 +3,7 @@ package org.koitharu.kotatsu.local
import org.koin.android.ext.koin.androidContext
import org.koin.androidx.viewmodel.dsl.viewModel
import org.koin.dsl.module
import org.koitharu.kotatsu.download.domain.DownloadManager
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.local.ui.LocalListViewModel
@ -16,5 +17,7 @@ val localModule
factory { ExternalStorageHelper(androidContext()) }
factory { DownloadManager.Factory(androidContext(), get(), get(), get(), get(), get()) }
viewModel { LocalListViewModel(get(), get(), get(), get()) }
}

@ -9,11 +9,11 @@ import coil.fetch.FetchResult
import coil.fetch.Fetcher
import coil.fetch.SourceResult
import coil.size.Size
import java.util.zip.ZipFile
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.buffer
import okio.source
import java.util.zip.ZipFile
class CbzFetcher : Fetcher<Uri> {

@ -7,6 +7,10 @@ import java.util.*
class CbzFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
return isFileSupported(name)
}
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.', "").lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}

@ -28,14 +28,17 @@ class MangaIndex(source: String?) {
json.put("state", manga.state?.name)
json.put("source", manga.source.name)
json.put("cover_large", manga.largeCoverUrl)
json.put("tags", JSONArray().also { a ->
for (tag in manga.tags) {
val jo = JSONObject()
jo.put("key", tag.key)
jo.put("title", tag.title)
a.put(jo)
json.put(
"tags",
JSONArray().also { a ->
for (tag in manga.tags) {
val jo = JSONObject()
jo.put("key", tag.key)
jo.put("title", tag.title)
a.put(jo)
}
}
})
)
if (!append || !json.has("chapters")) {
json.put("chapters", JSONObject())
}
@ -84,11 +87,15 @@ class MangaIndex(source: String?) {
jo.put("uploadDate", chapter.uploadDate)
jo.put("scanlator", chapter.scanlator)
jo.put("branch", chapter.branch)
jo.put("entries", "%03d\\d{3}".format(chapter.number))
jo.put("entries", "%08d_%03d\\d{3}".format(chapter.branch.hashCode(), chapter.number))
chapters.put(chapter.id.toString(), jo)
}
}
fun removeChapter(id: Long): Boolean {
return json.getJSONObject("chapters").remove(id.toString()) != null
}
fun setCoverEntry(name: String) {
json.put("cover_entry", name)
}

@ -1,70 +0,0 @@
package org.koitharu.kotatsu.local.data
import androidx.annotation.CheckResult
import androidx.annotation.WorkerThread
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.utils.ext.takeIfReadable
import java.io.File
@WorkerThread
class MangaZip(val file: File) {
private val writableCbz = WritableCbzFile(file)
private var index = MangaIndex(null)
suspend fun prepare(manga: Manga) {
writableCbz.prepare(overwrite = true)
index = MangaIndex(writableCbz[INDEX_ENTRY].takeIfReadable()?.readText())
index.setMangaInfo(manga, append = true)
}
suspend fun cleanup() {
writableCbz.cleanup()
}
@CheckResult
suspend fun compress(): Boolean {
writableCbz[INDEX_ENTRY].writeText(index.toString())
return writableCbz.flush()
}
suspend fun addCover(file: File, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
writableCbz.put(name, file)
index.setCoverEntry(name)
}
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
writableCbz.put(name, file)
index.addChapter(chapter)
}
companion object {
private const val FILENAME_PATTERN = "%03d%03d"
const val INDEX_ENTRY = "index.json"
fun findInDir(root: File, manga: Manga): MangaZip {
val name = manga.title.toFileNameSafe() + ".cbz"
val file = File(root, name)
return MangaZip(file)
}
}
}

@ -0,0 +1,11 @@
package org.koitharu.kotatsu.local.data
import java.io.File
import java.io.FilenameFilter
class TempFileFilter : FilenameFilter {
override fun accept(dir: File, name: String): Boolean {
return name.endsWith(".tmp", ignoreCase = true)
}
}

@ -1,99 +0,0 @@
package org.koitharu.kotatsu.local.data
import androidx.annotation.CheckResult
import kotlinx.coroutines.*
import org.koitharu.kotatsu.utils.ext.deleteAwait
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
class WritableCbzFile(private val file: File) {
private val dir = File(file.parentFile, file.nameWithoutExtension)
suspend fun prepare(overwrite: Boolean) = withContext(Dispatchers.IO) {
if (!dir.list().isNullOrEmpty()) {
if (overwrite) {
dir.deleteRecursively()
} else {
throw IllegalStateException("Dir ${dir.name} is not empty")
}
}
if (!dir.exists()) {
dir.mkdir()
}
if (!file.exists()) {
return@withContext
}
ZipInputStream(FileInputStream(file)).use { zip ->
var entry = zip.nextEntry
while (entry != null && currentCoroutineContext().isActive) {
val target = File(dir.path + File.separator + entry.name)
runInterruptible {
target.parentFile?.mkdirs()
target.outputStream().use { out ->
zip.copyTo(out)
}
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
suspend fun cleanup() = withContext(Dispatchers.IO) {
dir.deleteRecursively()
}
@CheckResult
suspend fun flush() = withContext(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) {
tempFile.deleteAwait()
}
try {
runInterruptible {
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
dir.listFiles()?.forEach {
zipFile(it, it.name, zip)
}
zip.flush()
}
}
tempFile.renameTo(file)
} finally {
if (tempFile.exists()) {
tempFile.deleteAwait()
}
}
}
operator fun get(name: String) = File(dir, name)
suspend fun put(name: String, file: File) = runInterruptible(Dispatchers.IO) {
file.copyTo(this[name], overwrite = true)
}
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
if (fileToZip.isDirectory) {
if (fileName.endsWith("/")) {
zipOut.putNextEntry(ZipEntry(fileName))
} else {
zipOut.putNextEntry(ZipEntry("$fileName/"))
}
zipOut.closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
}
} else {
FileInputStream(fileToZip).use { fis ->
val zipEntry = ZipEntry(fileName)
zipOut.putNextEntry(zipEntry)
fis.copyTo(zipOut)
}
}
}
}

@ -0,0 +1,153 @@
package org.koitharu.kotatsu.local.domain
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okio.Closeable
import org.koitharu.kotatsu.core.zip.ZipOutput
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.util.toFileNameSafe
import org.koitharu.kotatsu.utils.ext.deleteAwait
import org.koitharu.kotatsu.utils.ext.readText
import java.io.File
import java.util.zip.ZipFile
class CbzMangaOutput(
val file: File,
manga: Manga,
) : Closeable {
private val output = ZipOutput(File(file.path + ".tmp"))
private val index = MangaIndex(null)
init {
index.setMangaInfo(manga, false)
}
suspend fun mergeWithExisting() {
if (file.exists()) {
runInterruptible(Dispatchers.IO) {
mergeWith(file)
}
}
}
suspend fun addCover(file: File, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0, 0))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.setCoverEntry(name)
}
suspend fun addPage(chapter: MangaChapter, file: File, pageNumber: Int, ext: String) {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.branch.hashCode(), chapter.number, pageNumber))
if (ext.isNotEmpty() && ext.length <= 4) {
append('.')
append(ext)
}
}
runInterruptible(Dispatchers.IO) {
output.put(name, file)
}
index.addChapter(chapter)
}
suspend fun finalize() {
runInterruptible(Dispatchers.IO) {
output.put(ENTRY_NAME_INDEX, index.toString())
output.finish()
output.close()
}
file.deleteAwait()
output.file.renameTo(file)
}
suspend fun cleanup() {
output.file.deleteAwait()
}
override fun close() {
output.close()
}
@WorkerThread
private fun mergeWith(other: File) {
var otherIndex: MangaIndex? = null
ZipFile(other).use { zip ->
for (entry in zip.entries()) {
if (entry.name == ENTRY_NAME_INDEX) {
otherIndex = MangaIndex(
zip.getInputStream(entry).use {
it.reader().readText()
}
)
} else {
output.copyEntryFrom(zip, entry)
}
}
}
otherIndex?.getMangaInfo()?.chapters?.let { chapters ->
for (chapter in chapters) {
index.addChapter(chapter)
}
}
}
companion object {
private const val FILENAME_PATTERN = "%08d_%03d%03d"
const val ENTRY_NAME_INDEX = "index.json"
fun get(root: File, manga: Manga): CbzMangaOutput {
val name = manga.title.toFileNameSafe() + ".cbz"
val file = File(root, name)
return CbzMangaOutput(file, manga)
}
@WorkerThread
fun filterChapters(subject: CbzMangaOutput, idsToRemove: Set<Long>) {
ZipFile(subject.file).use { zip ->
val index = MangaIndex(zip.readText(zip.getEntry(ENTRY_NAME_INDEX)))
idsToRemove.forEach { id -> index.removeChapter(id) }
val patterns = requireNotNull(index.getMangaInfo()?.chapters).map {
index.getChapterNamesPattern(it)
}
val coverEntryName = index.getCoverEntry()
for (entry in zip.entries()) {
when {
entry.name == ENTRY_NAME_INDEX -> {
subject.output.put(ENTRY_NAME_INDEX, index.toString())
}
entry.isDirectory -> {
subject.output.addDirectory(entry.name)
}
entry.name == coverEntryName -> {
subject.output.copyEntryFrom(zip, entry)
}
else -> {
val name = entry.name.substringBefore('.')
if (patterns.any { it.matches(name) }) {
subject.output.copyEntryFrom(zip, entry)
}
}
}
}
subject.output.finish()
subject.output.close()
subject.file.delete()
subject.output.file.renameTo(subject.file)
}
}
}
}

@ -3,18 +3,17 @@ package org.koitharu.kotatsu.local.domain
import android.annotation.SuppressLint
import android.net.Uri
import android.webkit.MimeTypeMap
import androidx.annotation.WorkerThread
import androidx.collection.ArraySet
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import org.koitharu.kotatsu.core.exceptions.UnsupportedFileException
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.local.data.CbzFilter
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.data.MangaIndex
import org.koitharu.kotatsu.local.data.MangaZip
import org.koitharu.kotatsu.local.data.TempFileFilter
import org.koitharu.kotatsu.parsers.model.*
import org.koitharu.kotatsu.parsers.util.longHashCode
import org.koitharu.kotatsu.parsers.util.toCamelCase
@ -27,6 +26,9 @@ import java.io.IOException
import java.util.*
import java.util.zip.ZipEntry
import java.util.zip.ZipFile
import kotlin.coroutines.CoroutineContext
private const val MAX_PARALLELISM = 4
class LocalMangaRepository(private val storageManager: LocalStorageManager) : MangaRepository {
@ -39,27 +41,43 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
tags: Set<MangaTag>?,
sortOrder: SortOrder?
): List<Manga> {
require(offset == 0) {
"LocalMangaRepository does not support pagination"
if (offset > 0) {
return emptyList()
}
val files = getAllFiles()
return files.mapNotNull { x -> runCatching { getFromFile(x) }.getOrNull() }
val list = coroutineScope {
val dispatcher = Dispatchers.IO.limitedParallelism(MAX_PARALLELISM)
files.map { file ->
getFromFileAsync(file, dispatcher)
}.awaitAll()
}.filterNotNullTo(ArrayList(files.size))
if (!query.isNullOrEmpty()) {
list.retainAll { x ->
x.title.contains(query, ignoreCase = true) ||
x.altTitle?.contains(query, ignoreCase = true) == true
}
}
if (!tags.isNullOrEmpty()) {
list.retainAll { x ->
x.tags.containsAll(tags)
}
}
return list
}
override suspend fun getDetails(manga: Manga) = when {
manga.source != MangaSource.LOCAL -> requireNotNull(findSavedManga(manga)) {
"Manga is not local or saved"
}
manga.chapters == null -> getFromFile(Uri.parse(manga.url).toFile())
else -> manga
else -> getFromFile(Uri.parse(manga.url).toFile())
}
override suspend fun getPages(chapter: MangaChapter): List<MangaPage> {
return runInterruptible(Dispatchers.IO){
return runInterruptible(Dispatchers.IO) {
val uri = Uri.parse(chapter.url)
val file = uri.toFile()
val zip = ZipFile(file)
val index = zip.getEntry(MangaZip.INDEX_ENTRY)?.let(zip::readText)?.let(::MangaIndex)
val index = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)?.let(zip::readText)?.let(::MangaIndex)
var entries = zip.entries().asSequence()
entries = if (index != null) {
val pattern = index.getChapterNamesPattern(chapter)
@ -94,10 +112,18 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return file.deleteAwait()
}
suspend fun deleteChapters(manga: Manga, ids: Set<Long>) = runInterruptible(Dispatchers.IO) {
val uri = Uri.parse(manga.url)
val file = uri.toFile()
val cbz = CbzMangaOutput(file, manga)
CbzMangaOutput.filterChapters(cbz, ids)
}
@WorkerThread
@SuppressLint("DefaultLocale")
fun getFromFile(file: File): Manga = ZipFile(file).use { zip ->
val fileUri = file.toUri().toString()
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
val info = index?.getMangaInfo()
if (index != null && info != null) {
@ -158,7 +184,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}.getOrNull() ?: return null
return runInterruptible(Dispatchers.IO) {
ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
val index = entry?.let(zip::readText)?.let(::MangaIndex)
index?.getMangaInfo()
}
@ -170,7 +196,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
return runInterruptible(Dispatchers.IO) {
for (file in files) {
val index = ZipFile(file).use { zip ->
val entry = zip.getEntry(MangaZip.INDEX_ENTRY)
val entry = zip.getEntry(CbzMangaOutput.ENTRY_NAME_INDEX)
entry?.let(zip::readText)?.let(::MangaIndex)
} ?: continue
val info = index.getMangaInfo() ?: continue
@ -187,6 +213,15 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}
}
private fun CoroutineScope.getFromFileAsync(
file: File,
context: CoroutineContext,
): Deferred<Manga?> = async(context) {
runInterruptible {
runCatching { getFromFile(file) }.getOrNull()
}
}
private fun zipUri(file: File, entryName: String) = "cbz://${file.path}#$entryName"
private fun findFirstImageEntry(entries: Enumeration<out ZipEntry>): ZipEntry? {
@ -211,7 +246,7 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
withContext(Dispatchers.IO) {
val name = contentResolver.resolveName(uri)
?: throw IOException("Cannot fetch name from uri: $uri")
if (!isFileSupported(name)) {
if (!filenameFilter.isFileSupported(name)) {
throw UnsupportedFileException("Unsupported file on $uri")
}
val dest = File(
@ -228,15 +263,21 @@ class LocalMangaRepository(private val storageManager: LocalStorageManager) : Ma
}
}
fun isFileSupported(name: String): Boolean {
val ext = name.substringAfterLast('.').lowercase(Locale.ROOT)
return ext == "cbz" || ext == "zip"
}
suspend fun getOutputDir(): File? {
return storageManager.getDefaultWriteableDir()
}
suspend fun cleanup() {
val dirs = storageManager.getWriteableDirs()
runInterruptible(Dispatchers.IO) {
dirs.flatMap { dir ->
dir.listFiles(TempFileFilter())?.toList().orEmpty()
}.forEach { file ->
file.delete()
}
}
}
private suspend fun getAllFiles() = storageManager.getReadableDirs().flatMap { dir ->
dir.listFiles(filenameFilter)?.toList().orEmpty()
}

@ -0,0 +1,80 @@
package org.koitharu.kotatsu.local.ui
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat
import androidx.core.app.ServiceCompat
import androidx.core.content.ContextCompat
import org.koin.android.ext.android.inject
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.base.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.local.domain.LocalMangaRepository
import org.koitharu.kotatsu.parsers.model.Manga
class LocalChaptersRemoveService : CoroutineIntentService() {
private val localMangaRepository by inject<LocalMangaRepository>()
override suspend fun processIntent(intent: Intent?) {
val manga = intent?.getParcelableExtra<ParcelableManga>(EXTRA_MANGA)?.manga ?: return
val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return
startForeground()
val mangaWithChapters = localMangaRepository.getDetails(manga)
localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds)
sendBroadcast(
Intent(DownloadService.ACTION_DOWNLOAD_COMPLETE)
.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
)
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
}
private fun startForeground() {
val title = getString(R.string.local_manga_processing)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val manager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val channel = NotificationChannel(CHANNEL_ID, title, NotificationManager.IMPORTANCE_LOW)
channel.setShowBadge(false)
channel.enableVibration(false)
channel.setSound(null, null)
channel.enableLights(false)
manager.createNotificationChannel(channel)
}
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setPriority(NotificationCompat.PRIORITY_MIN)
.setDefaults(0)
.setColor(ContextCompat.getColor(this, R.color.blue_primary_dark))
.setSilent(true)
.setProgress(0, 0, true)
.setSmallIcon(android.R.drawable.stat_notify_sync)
.setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_DEFERRED)
.setOngoing(true)
.build()
startForeground(NOTIFICATION_ID, notification)
}
companion object {
private const val CHANNEL_ID = "local_processing"
private const val NOTIFICATION_ID = 21
private const val EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"
fun start(context: Context, manga: Manga, chaptersIds: Collection<Long>) {
if (chaptersIds.isEmpty()) {
return
}
val intent = Intent(context, LocalChaptersRemoveService::class.java)
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga, withChapters = false))
intent.putExtra(EXTRA_CHAPTERS_IDS, chaptersIds.toLongArray())
ContextCompat.startForegroundService(context, intent)
}
}
}

@ -3,16 +3,18 @@ package org.koitharu.kotatsu.local.ui
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.viewModelScope
import java.io.IOException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.ShortcutsRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.download.ui.service.DownloadService
import org.koitharu.kotatsu.history.domain.HistoryRepository
import org.koitharu.kotatsu.list.ui.MangaListViewModel
import org.koitharu.kotatsu.list.ui.model.*
@ -21,6 +23,7 @@ import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.ext.asLiveDataDistinct
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.IOException
class LocalListViewModel(
private val repository: LocalMangaRepository,
@ -64,6 +67,7 @@ class LocalListViewModel(
init {
onRefresh()
cleanup()
}
override fun onRefresh() {
@ -116,4 +120,18 @@ class LocalListViewModel(
listError.value = e
}
}
private fun cleanup() {
if (!DownloadService.isRunning) {
viewModelScope.launch {
runCatching {
repository.cleanup()
}.onFailure { error ->
if (BuildConfig.DEBUG) {
error.printStackTrace()
}
}
}
}
}
}

@ -34,7 +34,7 @@ class AppProtectHelper(private val settings: AppSettings) : Application.Activity
override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) = Unit
override fun onActivityDestroyed(activity: Activity) {
if (activity !is ProtectActivity && activity.isTaskRoot) {
if (activity !is ProtectActivity && activity.isFinishing && activity.isTaskRoot) {
restoreLock()
}
}

@ -137,6 +137,9 @@ class RemoteListViewModel(
e.printStackTrace()
}
listError.value = e
if (!mangaList.value.isNullOrEmpty()) {
onError.postCall(e)
}
}
}
}

@ -12,6 +12,7 @@ import org.koitharu.kotatsu.base.ui.BasePreferenceFragment
import org.koitharu.kotatsu.base.ui.dialog.StorageSelectDialog
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.settings.utils.SliderPreference
import org.koitharu.kotatsu.utils.ext.getStorageName
import org.koitharu.kotatsu.utils.ext.viewLifecycleScope
@ -28,6 +29,13 @@ class ContentSettingsFragment :
findPreference<Preference>(AppSettings.KEY_SUGGESTIONS)?.setSummary(
if (settings.isSuggestionsEnabled) R.string.enabled else R.string.disabled
)
findPreference<SliderPreference>(AppSettings.KEY_DOWNLOADS_PARALLELISM)?.run {
summary = value.toString()
setOnPreferenceChangeListener { preference, newValue ->
preference.summary = newValue.toString()
true
}
}
bindRemoteSourcesSummary()
}

@ -1,15 +1,15 @@
package org.koitharu.kotatsu.settings
import android.view.inputmethod.EditorInfo
import androidx.preference.*
import androidx.preference.EditTextPreference
import androidx.preference.Preference
import androidx.preference.PreferenceFragmentCompat
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.RemoteMangaRepository
import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.settings.utils.AutoCompleteTextViewPreference
import org.koitharu.kotatsu.settings.utils.EditTextBindListener
import org.koitharu.kotatsu.settings.utils.EditTextDefaultSummaryProvider
import org.koitharu.kotatsu.utils.ext.setDefaultValueCompat
private const val KEY_DOMAIN = "domain"
fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMangaRepository) {
val configKeys = repository.getConfigKeys()
@ -19,23 +19,17 @@ fun PreferenceFragmentCompat.addPreferencesFromRepository(repository: RemoteMang
is ConfigKey.Domain -> {
val presetValues = key.presetValues
if (presetValues.isNullOrEmpty()) {
EditTextPreference(requireContext()).apply {
summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue)
setOnBindEditTextListener(
EditTextBindListener(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI,
hint = key.defaultValue,
)
)
}
EditTextPreference(requireContext())
} else {
DropDownPreference(requireContext()).apply {
entries = presetValues
entryValues = entries
summaryProvider = ListPreference.SimpleSummaryProvider.getInstance()
setDefaultValueCompat(key.defaultValue)
}
AutoCompleteTextViewPreference(requireContext()).apply { entries = presetValues }
}.apply {
summaryProvider = EditTextDefaultSummaryProvider(key.defaultValue)
setOnBindEditTextListener(
EditTextBindListener(
inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI,
hint = key.defaultValue,
)
)
setTitle(R.string.domain)
setDialogTitle(R.string.domain)
}

@ -5,12 +5,8 @@ import android.app.backup.BackupDataInput
import android.app.backup.BackupDataOutput
import android.app.backup.FullBackupDataOutput
import android.os.ParcelFileDescriptor
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.core.backup.BackupArchive
import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.core.backup.*
import org.koitharu.kotatsu.core.db.MangaDatabase
import java.io.*
@ -46,7 +42,7 @@ class AppBackupAgent : BackupAgent() {
mode: Long,
mtime: Long
) {
if (destination?.name?.endsWith(".bak") == true) {
if (destination?.name?.endsWith(".bk.zip") == true) {
restoreBackupFile(data.fileDescriptor, size)
destination.delete()
} else {
@ -56,14 +52,14 @@ class AppBackupAgent : BackupAgent() {
private fun createBackupFile() = runBlocking {
val repository = BackupRepository(MangaDatabase.create(applicationContext))
val backup = BackupArchive.createNew(this@AppBackupAgent)
backup.put(repository.createIndex())
backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites())
backup.flush()
backup.cleanup()
backup.file
BackupZipOutput(this@AppBackupAgent).use { backup ->
backup.put(repository.createIndex())
backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites())
backup.finish()
backup.file
}
}
private fun restoreBackupFile(fd: FileDescriptor, size: Long) {
@ -74,18 +70,15 @@ class AppBackupAgent : BackupAgent() {
input.copyLimitedTo(output, size)
}
}
val backup = BackupArchive(tempFile)
val backup = BackupZipInput(tempFile)
try {
runBlocking {
backup.unpack()
repository.upsertHistory(backup.getEntry(BackupEntry.HISTORY))
repository.upsertCategories(backup.getEntry(BackupEntry.CATEGORIES))
repository.upsertFavourites(backup.getEntry(BackupEntry.FAVOURITES))
}
} finally {
runBlocking(NonCancellable) {
backup.cleanup()
}
backup.close()
tempFile.delete()
}
}

@ -3,8 +3,8 @@ package org.koitharu.kotatsu.settings.backup
import android.content.Context
import androidx.lifecycle.MutableLiveData
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.backup.BackupArchive
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
@ -19,23 +19,25 @@ class BackupViewModel(
init {
launchLoadingJob {
val backup = BackupArchive.createNew(context)
backup.put(repository.createIndex())
val file = BackupZipOutput(context).use { backup ->
backup.put(repository.createIndex())
progress.value = Progress(0, 3)
backup.put(repository.dumpHistory())
progress.value = Progress(0, 3)
backup.put(repository.dumpHistory())
progress.value = Progress(1, 3)
backup.put(repository.dumpCategories())
progress.value = Progress(1, 3)
backup.put(repository.dumpCategories())
progress.value = Progress(2, 3)
backup.put(repository.dumpFavourites())
progress.value = Progress(2, 3)
backup.put(repository.dumpFavourites())
progress.value = Progress(3, 3)
backup.flush()
progress.value = null
backup.cleanup()
onBackupDone.call(backup.file)
progress.value = Progress(3, 3)
backup.finish()
progress.value = null
backup.close()
backup.file
}
onBackupDone.call(file)
}
}
}

@ -3,19 +3,17 @@ package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.net.Uri
import androidx.lifecycle.MutableLiveData
import java.io.File
import java.io.FileNotFoundException
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.base.ui.BaseViewModel
import org.koitharu.kotatsu.core.backup.BackupArchive
import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.core.backup.BackupZipInput
import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.backup.RestoreRepository
import org.koitharu.kotatsu.utils.SingleLiveEvent
import org.koitharu.kotatsu.utils.progress.Progress
import java.io.File
import java.io.FileNotFoundException
class RestoreViewModel(
uri: Uri?,
@ -40,10 +38,9 @@ class RestoreViewModel(
input.copyTo(output)
}
}
BackupArchive(tempFile)
BackupZipInput(tempFile)
}
try {
backup.unpack()
val result = CompositeResult()
progress.value = Progress(0, 3)
@ -58,10 +55,8 @@ class RestoreViewModel(
progress.value = Progress(3, 3)
onRestoreDone.call(result)
} finally {
withContext(NonCancellable) {
backup.cleanup()
backup.file.delete()
}
backup.close()
backup.file.delete()
}
}
}

@ -0,0 +1,58 @@
package org.koitharu.kotatsu.settings.utils
import android.content.Context
import android.util.AttributeSet
import android.view.View
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.AutoCompleteTextView
import android.widget.EditText
import androidx.annotation.ArrayRes
import androidx.annotation.AttrRes
import androidx.annotation.StyleRes
import androidx.preference.EditTextPreference
import org.koitharu.kotatsu.R
class AutoCompleteTextViewPreference @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
@AttrRes defStyleAttr: Int = R.attr.autoCompleteTextViewPreferenceStyle,
@StyleRes defStyleRes: Int = R.style.Preference_AutoCompleteTextView,
) : EditTextPreference(context, attrs, defStyleAttr, defStyleRes) {
private val autoCompleteBindListener = AutoCompleteBindListener()
var entries: Array<String> = emptyArray()
init {
super.setOnBindEditTextListener(autoCompleteBindListener)
}
fun setEntries(@ArrayRes arrayResId: Int) {
this.entries = context.resources.getStringArray(arrayResId)
}
fun setEntries(entries: Collection<String>) {
this.entries = entries.toTypedArray()
}
override fun setOnBindEditTextListener(onBindEditTextListener: OnBindEditTextListener?) {
autoCompleteBindListener.delegate = onBindEditTextListener
}
private inner class AutoCompleteBindListener : OnBindEditTextListener {
var delegate: OnBindEditTextListener? = null
override fun onBindEditText(editText: EditText) {
delegate?.onBindEditText(editText)
if (editText !is AutoCompleteTextView || entries.isEmpty()) {
return
}
editText.threshold = 0
editText.setAdapter(ArrayAdapter(editText.context, android.R.layout.simple_spinner_dropdown_item, entries))
(editText.parent as? ViewGroup)?.findViewById<View>(R.id.dropdown)?.setOnClickListener {
editText.showDropDown()
}
}
}
}

@ -1,103 +0,0 @@
package org.koitharu.kotatsu.utils
import androidx.annotation.WorkerThread
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.withContext
import java.io.File
import java.io.FileInputStream
import java.io.FileOutputStream
import java.util.zip.ZipEntry
import java.util.zip.ZipInputStream
import java.util.zip.ZipOutputStream
open class MutableZipFile(val file: File) {
protected val dir = File(file.parentFile, file.nameWithoutExtension)
suspend fun unpack(): Unit = runInterruptible(Dispatchers.IO) {
check(dir.list().isNullOrEmpty()) {
"Dir ${dir.name} is not empty"
}
if (!dir.exists()) {
dir.mkdir()
}
if (!file.exists()) {
return@runInterruptible
}
ZipInputStream(FileInputStream(file)).use { zip ->
var entry = zip.nextEntry
while (entry != null) {
val target = File(dir.path + File.separator + entry.name)
target.parentFile?.mkdirs()
target.outputStream().use { out ->
zip.copyTo(out)
}
zip.closeEntry()
entry = zip.nextEntry
}
}
}
suspend fun cleanup() = withContext(Dispatchers.IO) {
dir.deleteRecursively()
}
suspend fun flush(): Boolean = runInterruptible(Dispatchers.IO) {
val tempFile = File(file.path + ".tmp")
if (tempFile.exists()) {
tempFile.delete()
}
try {
ZipOutputStream(FileOutputStream(tempFile)).use { zip ->
dir.listFiles()?.forEach {
zipFile(it, it.name, zip)
}
zip.flush()
}
tempFile.renameTo(file)
} finally {
if (tempFile.exists()) {
tempFile.delete()
}
}
}
operator fun get(name: String) = File(dir, name)
suspend fun put(name: String, file: File): Unit = withContext(Dispatchers.IO) {
file.copyTo(this@MutableZipFile[name], overwrite = true)
}
suspend fun put(name: String, data: String): Unit = withContext(Dispatchers.IO) {
this@MutableZipFile[name].writeText(data)
}
suspend fun getContent(name: String): String = withContext(Dispatchers.IO) {
get(name).readText()
}
companion object {
@WorkerThread
private fun zipFile(fileToZip: File, fileName: String, zipOut: ZipOutputStream) {
if (fileToZip.isDirectory) {
if (fileName.endsWith("/")) {
zipOut.putNextEntry(ZipEntry(fileName))
} else {
zipOut.putNextEntry(ZipEntry("$fileName/"))
}
zipOut.closeEntry()
fileToZip.listFiles()?.forEach { childFile ->
zipFile(childFile, "$fileName/${childFile.name}", zipOut)
}
} else {
FileInputStream(fileToZip).use { fis ->
val zipEntry = ZipEntry(fileName)
zipOut.putNextEntry(zipEntry)
fis.copyTo(zipOut)
}
}
}
}
}

@ -0,0 +1,50 @@
package org.koitharu.kotatsu.utils
import androidx.annotation.MainThread
import java.util.concurrent.ConcurrentLinkedQueue
import kotlin.coroutines.CoroutineContext
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.Runnable
class PausingDispatcher(
private val dispatcher: CoroutineDispatcher,
) : CoroutineDispatcher() {
@Volatile
private var isPaused = false
private val queue = ConcurrentLinkedQueue<Task>()
override fun isDispatchNeeded(context: CoroutineContext): Boolean {
return isPaused || super.isDispatchNeeded(context)
}
override fun dispatch(context: CoroutineContext, block: Runnable) {
if (isPaused) {
queue.add(Task(context, block))
} else {
dispatcher.dispatch(context, block)
}
}
@MainThread
fun pause() {
isPaused = true
}
@MainThread
fun resume() {
if (!isPaused) {
return
}
isPaused = false
while (true) {
val task = queue.poll() ?: break
dispatcher.dispatch(task.context, task.block)
}
}
private class Task(
val context: CoroutineContext,
val block: Runnable,
)
}

@ -5,6 +5,7 @@ import android.net.ConnectivityManager
import android.net.Network
import android.net.NetworkRequest
import android.net.Uri
import android.os.Build
import androidx.work.CoroutineWorker
import kotlin.coroutines.resume
import kotlinx.coroutines.suspendCancellableCoroutine
@ -14,9 +15,14 @@ val Context.connectivityManager: ConnectivityManager
suspend fun ConnectivityManager.waitForNetwork(): Network {
val request = NetworkRequest.Builder().build()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
// fast path
activeNetwork?.let { return it }
}
return suspendCancellableCoroutine { cont ->
val callback = object : ConnectivityManager.NetworkCallback() {
override fun onAvailable(network: Network) {
unregisterNetworkCallback(this)
if (cont.isActive) {
cont.resume(network)
}

@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize
data class Progress(
val value: Int,
val total: Int
val total: Int,
) : Parcelable, Comparable<Progress> {
override fun compareTo(other: Progress): Int {

@ -0,0 +1,47 @@
package org.koitharu.kotatsu.utils.progress
import android.os.SystemClock
import kotlin.math.roundToInt
import kotlin.math.roundToLong
private const val MIN_ESTIMATE_TICKS = 4
private const val NO_TIME = -1L
class TimeLeftEstimator {
private var times = ArrayList<Int>()
private var lastTick: Tick? = null
fun tick(value: Int, total: Int) {
if (total < 0) {
emptyTick()
return
}
val tick = Tick(value, total, SystemClock.elapsedRealtime())
lastTick?.let {
val ticksCount = value - it.value
times.add(((tick.time - it.time) / ticksCount.toDouble()).roundToInt())
}
lastTick = tick
}
fun emptyTick() {
lastTick = null
}
fun getEstimatedTimeLeft(): Long {
val progress = lastTick ?: return NO_TIME
if (times.size < MIN_ESTIMATE_TICKS) {
return NO_TIME
}
val timePerTick = times.average()
val ticksLeft = progress.total - progress.value
return (ticksLeft * timePerTick).roundToLong()
}
private class Tick(
val value: Int,
val total: Int,
val time: Long,
)
}

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M14,19H18V5H14M6,19H10V5H6V19Z" />
</vector>

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<vector
xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#000"
android:pathData="M8,5.14V19.14L19,12.14L8,5.14Z" />
</vector>

@ -11,7 +11,7 @@
android:bottom="2dp"
android:left="2dp">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<corners android:radius="@dimen/list_selector_corner" />
<solid android:color="@color/selector_overlay" />
</shape>
</item>
@ -22,7 +22,7 @@
android:bottom="2dp"
android:left="2dp">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<corners android:radius="@dimen/list_selector_corner" />
<solid android:color="@color/selector_overlay" />
</shape>
</item>
@ -32,7 +32,7 @@
android:bottom="2dp"
android:left="2dp">
<shape android:shape="rectangle">
<corners android:radius="12dp" />
<corners android:radius="@dimen/list_selector_corner" />
<solid android:color="?android:attr/windowBackground" />
</shape>
</item>

@ -13,7 +13,10 @@
android:layout_height="match_parent"
android:clipToPadding="false"
android:orientation="vertical"
android:padding="@dimen/grid_spacing_outer"
android:paddingLeft="@dimen/list_spacing"
android:paddingRight="@dimen/list_spacing"
android:paddingTop="@dimen/grid_spacing_outer"
android:paddingBottom="@dimen/grid_spacing_outer"
app:fastScrollEnabled="true"
tools:layoutManager="org.koitharu.kotatsu.base.ui.list.FitHeightLinearLayoutManager"
tools:listitem="@layout/item_manga_list" />

@ -9,17 +9,15 @@
android:clipChildren="false"
android:padding="@dimen/list_spacing">
<org.koitharu.kotatsu.base.ui.widgets.CoverImageView
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="48dp"
android:layout_width="42dp"
android:layout_height="0dp"
android:contentDescription="@null"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintVertical_bias="0.0"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
tools:src="@tools:sample/backgrounds/scenic" />
@ -28,13 +26,13 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="2dp"
android:ellipsize="end"
android:singleLine="true"
android:textAppearance="?attr/textAppearanceBodyLarge"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toTopOf="@+id/textView_summary"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="@id/imageView_cover"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
tools:text="@tools:sample/lorem" />
<TextView
@ -42,15 +40,13 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="2dp"
android:ellipsize="end"
android:singleLine="true"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintBottom_toBottomOf="@id/imageView_cover"
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_title"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toBottomOf="@+id/textView_title"
tools:text="@string/new_chapters" />
</androidx.constraintlayout.widget.ConstraintLayout>
</androidx.constraintlayout.widget.ConstraintLayout>

@ -1,55 +1,52 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView
<androidx.constraintlayout.widget.ConstraintLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="@dimen/manga_list_item_height"
app:cardCornerRadius="12dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="horizontal">
<org.koitharu.kotatsu.base.ui.widgets.CoverImageView
android:id="@+id/imageView_cover"
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:orientation="vertical"
android:scaleType="centerCrop"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
tools:src="@tools:sample/backgrounds/scenic" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="center_vertical"
android:orientation="vertical"
android:paddingStart="20dp"
android:paddingEnd="16dp">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="2"
android:textAppearance="?attr/textAppearanceBodyLarge"
tools:text="@tools:sample/lorem/random" />
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:textColorSecondary"
tools:text="@tools:sample/lorem/random" />
</LinearLayout>
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
android:layout_height="wrap_content"
android:background="@drawable/list_selector"
android:clipChildren="false"
android:padding="@dimen/list_spacing">
<com.google.android.material.imageview.ShapeableImageView
android:id="@+id/imageView_cover"
android:layout_width="42dp"
android:layout_height="0dp"
android:scaleType="centerCrop"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintDimensionRatio="h,1:1"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover.Small"
tools:src="@tools:sample/backgrounds/scenic" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintBottom_toTopOf="@+id/textView_subtitle"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toTopOf="@+id/imageView_cover"
tools:text="Title" />
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:ellipsize="end"
android:maxLines="1"
android:textAppearance="?attr/textAppearanceBodySmall"
app:layout_constraintBottom_toBottomOf="@+id/imageView_cover"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/imageView_cover"
app:layout_constraintTop_toBottomOf="@+id/textView_title"
tools:text="Description" />
</androidx.constraintlayout.widget.ConstraintLayout>

@ -0,0 +1,59 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:layout_marginTop="48dp"
android:layout_marginBottom="48dp"
android:overScrollMode="ifContentScrolls">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:id="@android:id/message"
style="?android:attr/textAppearanceSmall"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="24dp"
android:layout_marginLeft="24dp"
android:layout_marginEnd="24dp"
android:layout_marginRight="24dp"
android:layout_marginBottom="48dp"
android:labelFor="@android:id/edit"
android:textColor="?android:attr/textColorSecondary"
tools:ignore="LabelFor" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginStart="20dp"
android:layout_marginLeft="20dp"
android:layout_marginEnd="20dp"
android:layout_marginRight="20dp">
<AutoCompleteTextView
android:id="@android:id/edit"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:importantForAutofill="no"
android:minHeight="48dp" />
<ImageView
android:id="@+id/dropdown"
android:layout_width="48dp"
android:layout_height="48dp"
android:layout_gravity="center_vertical|end"
android:background="?selectableItemBackgroundBorderless"
android:paddingBottom="2dp"
android:scaleType="center"
android:src="@drawable/ic_expand_more"
tools:ignore="ContentDescription" />
</FrameLayout>
</LinearLayout>
</ScrollView>

@ -5,13 +5,12 @@
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:baselineAligned="false"
android:clipChildren="false"
android:clipToPadding="false"
android:orientation="horizontal"
android:gravity="center_vertical"
android:minHeight="?android:attr/listPreferredItemHeightSmall"
android:orientation="horizontal"
android:paddingStart="?android:attr/listPreferredItemPaddingStart"
android:paddingEnd="?android:attr/listPreferredItemPaddingEnd"
tools:ignore="PrivateResource">
@ -27,17 +26,18 @@
android:clipToPadding="false"
android:orientation="vertical">
<RelativeLayout
android:layout_width="wrap_content"
android:layout_height="0dp"
android:layout_weight="1">
<LinearLayout
android:layout_width="match_parent"
android:orientation="horizontal"
android:baselineAligned="true"
android:baselineAlignedChildIndex="0"
android:layout_height="wrap_content">
<TextView
android:id="@android:id/title"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_toStartOf="@android:id/summary"
android:ellipsize="marquee"
android:labelFor="@id/seekbar"
android:singleLine="true"
@ -49,12 +49,11 @@
style="@style/PreferenceSummaryTextStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_alignParentEnd="true"
android:singleLine="true"
android:textAlignment="viewStart"
android:textColor="?android:attr/textColorSecondary" />
</RelativeLayout>
</LinearLayout>
<com.google.android.material.slider.Slider
android:id="@+id/slider"

@ -9,6 +9,12 @@
android:title="@string/save"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_delete"
android:icon="@drawable/ic_delete"
android:title="@string/delete"
app:showAsAction="ifRoom|withText" />
<item
android:id="@+id/action_select_all"
android:icon="?actionModeSelectAllDrawable"

@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<menu
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:icon="@drawable/ic_pause"
android:id="@+id/action_pause"
android:title="Pause"
app:showAsAction="ifRoom|withText" />
<item
android:icon="@drawable/ic_resume"
android:id="@+id/action_resume"
android:title="Resume"
app:showAsAction="ifRoom|withText" />
</menu>

@ -271,4 +271,9 @@
<string name="text_delete_local_manga_batch">Удалить выбранную мангу с накопителя?</string>
<string name="removal_completed">Удаление завершено</string>
<string name="batch_manga_save_confirm">Загрузить выбранную мангу со всеми главами? Это может привести к большому расходу трафика и места на накопителе</string>
<string name="parallel_downloads">Загружать параллельно</string>
<string name="download_slowdown">Замедление загрузки</string>
<string name="download_slowdown_summary">Помогает избежать блокировки IP-адреса</string>
<string name="local_manga_processing">Обработка сохранённой манги</string>
<string name="chapters_will_removed_background">Главы будут удалены в фоновом режиме. Это может занять какое-то время</string>
</resources>

@ -53,7 +53,7 @@
<string name="share_s">Dela %s</string>
<string name="search_manga">Sök manga</string>
<string name="cache">Cache</string>
<string name="show_notification_app_update">Visa en notifikation om en nyare version av äpplen finns tillgänglig</string>
<string name="show_notification_app_update">Visa en avisering om en nyare version av appen finns tillgänglig</string>
<string name="newest">Nyaste</string>
<string name="sort_order">Sorteringsordning</string>
<string name="dark">Mörkt</string>
@ -90,25 +90,25 @@
<string name="search_history_cleared">Rensat</string>
<string name="clear_thumbs_cache">Rensa cache för miniatyrer</string>
<string name="open_in_browser">Öppna i webbläsare</string>
<string name="notifications">Notifikationer</string>
<string name="notifications">Aviseringar</string>
<string name="clear_search_history">Rensa sökhistorik</string>
<string name="domain">Domän</string>
<string name="gestures_only">Endast gester</string>
<string name="internal_storage">Internlagring</string>
<string name="external_storage">Externlagring</string>
<string name="application_update">Kolla om det finns en nyare version av appen</string>
<string name="application_update">Håll appen uppdaterad</string>
<string name="app_update_available">En ny version av appen finns tillgänglig</string>
<string name="enabled_d_of_d" tools:ignore="PluralsCandidate">%1$d av %2$d aktiva</string>
<string name="new_chapters">Nya kapitel</string>
<string name="large_manga_save_confirm">Den här mangan har %s. Spara hela\?</string>
<string name="save_manga">Spara</string>
<string name="download">Ladda ned</string>
<string name="notifications_settings">Notifikationsinställningar</string>
<string name="notifications_settings">Aviseringsinställningar</string>
<string name="light_indicator">LED-indikator</string>
<string name="show_notification_new_chapters">Notifiera om uppdateringar på manga du läser</string>
<string name="show_notification_new_chapters">Avisera om uppdateringar på manga du läser</string>
<string name="read_from_start">Läs från början</string>
<string name="restart">Starta om</string>
<string name="notification_sound">Notifikationsljud</string>
<string name="notification_sound">Aviseringsljud</string>
<string name="vibration">Vibration</string>
<string name="favourites_categories">Favoritkategorier</string>
<string name="no_update_available">Inga tillgängliga uppdateringar</string>

@ -3,6 +3,7 @@
<attr name="sliderPreferenceStyle" />
<attr name="multiAutoCompleteTextViewPreferenceStyle" />
<attr name="autoCompleteTextViewPreferenceStyle" />
<attr name="listItemTextViewStyle" />
<declare-styleable name="Theme">

@ -20,6 +20,7 @@
<dimen name="list_footer_height_outer">48dp</dimen>
<dimen name="screen_padding">16dp</dimen>
<dimen name="selection_stroke_width">2dp</dimen>
<dimen name="list_selector_corner">12dp</dimen>
<dimen name="search_suggestions_manga_height">124dp</dimen>
<dimen name="search_suggestions_manga_spacing">4dp</dimen>

@ -274,4 +274,9 @@
<string name="text_delete_local_manga_batch">Delete selected items from device permanently?</string>
<string name="removal_completed">Removal completed</string>
<string name="batch_manga_save_confirm">Are you sure you want to download all selected manga with all its chapters? This action can consume a lot of traffic and storage</string>
<string name="parallel_downloads">Parallel downloads</string>
<string name="download_slowdown">Download slowdown</string>
<string name="download_slowdown_summary">Helps avoid blocking your IP address</string>
<string name="local_manga_processing">Saved manga processing</string>
<string name="chapters_will_removed_background">Chapters will be removed in the background. It can take some time</string>
</resources>

@ -156,4 +156,8 @@
<item name="android:dialogLayout">@layout/preference_dialog_multiautocompletetextview</item>
</style>
<style name="Preference.AutoCompleteTextView" parent="Preference.DialogPreference.EditTextPreference.Material">
<item name="android:dialogLayout">@layout/preference_dialog_autocompletetextview</item>
</style>
</resources>

@ -8,17 +8,32 @@
android:key="remote_sources"
android:title="@string/remote_sources" />
<Preference
android:key="local_storage"
android:persistent="false"
android:title="@string/manga_save_location" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.SuggestionsSettingsFragment"
android:key="suggestions"
android:persistent="false"
android:title="@string/suggestions" />
<Preference
android:key="local_storage"
android:persistent="false"
android:title="@string/manga_save_location"
app:allowDividerAbove="true" />
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="downloads_slowdown"
android:summary="@string/download_slowdown_summary"
android:title="@string/download_slowdown" />
<org.koitharu.kotatsu.settings.utils.SliderPreference
android:key="downloads_parallelism"
android:stepSize="1"
android:title="@string/parallel_downloads"
android:valueFrom="1"
android:valueTo="5"
app:defaultValue="2" />
<PreferenceScreen
android:fragment="org.koitharu.kotatsu.settings.backup.BackupSettingsFragment"
android:title="@string/backup_restore"

Loading…
Cancel
Save