Merge branch 'feature/direct-download' into devel

This commit is contained in:
Koitharu
2022-04-23 13:57:24 +03:00
45 changed files with 1028 additions and 526 deletions

View File

@@ -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" />

View File

@@ -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,

View File

@@ -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?)
}

View File

@@ -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)
}
}
}

View File

@@ -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))
}
}
}

View File

@@ -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()
}
}

View File

@@ -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))
}

View File

@@ -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"

View File

@@ -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
}
}

View File

@@ -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
}

View File

@@ -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)
}
}
}
}

View File

@@ -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 }
}

View File

@@ -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

View File

@@ -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,
)
}
}

View File

@@ -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)

View File

@@ -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
private const val ACTION_DOWNLOAD_CANCEL =
"${BuildConfig.APPLICATION_ID}.action.ACTION_DOWNLOAD_CANCEL"
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 EXTRA_MANGA = "manga"
private const val EXTRA_CHAPTERS_IDS = "chapters_ids"

View File

@@ -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()) }
}

View File

@@ -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> {

View File

@@ -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"
}

View File

@@ -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)
}

View File

@@ -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)
}
}
}

View 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)
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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)
}
}
}
}

View 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()
}

View File

@@ -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)
}
}
}

View File

@@ -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()
}
}
}
}
}
}

View File

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

View File

@@ -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()
}

View File

@@ -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()
}
}

View File

@@ -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)
}
}
}

View 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()
}
}
}

View File

@@ -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)
}
}
}
}
}

View File

@@ -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,
)
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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,
)
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"