Compare commits

...

19 Commits

Author SHA1 Message Date
Koitharu
4bb0d52217 Fix downloading 2023-10-28 16:39:43 +03:00
Koitharu
66de4bd49e Translated using Weblate (Russian)
Currently translated at 100.0% (508 of 508 strings)

Translated using Weblate (Russian)

Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: Koitharu <nvasya95@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Bai
ff12d63696 Translated using Weblate (Turkish)
Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: Bai <batuhanakkurt000@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
InfinityDouki56
c168a841f3 Translated using Weblate (Filipino)
Currently translated at 88.9% (451 of 507 strings)

Co-authored-by: InfinityDouki56 <ced.paltep10@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/fil/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
pro maxime
8bfb676e6a Translated using Weblate (Arabic)
Currently translated at 36.0% (183 of 507 strings)

Translated using Weblate (Arabic)

Currently translated at 100.0% (7 of 7 strings)

Co-authored-by: pro maxime <promaxime45@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/plurals/ar/
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ar/
Translation: Kotatsu/Strings
Translation: Kotatsu/plurals
2023-10-28 16:16:39 +03:00
gallegonovato
d5c0ce280e Translated using Weblate (Spanish)
Currently translated at 100.0% (507 of 507 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Vinícius Saturnino
b34627c361 Translated using Weblate (Portuguese)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Vinícius Saturnino <saturninodepaulavinicius62@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Paulo Oliveira
cbc3be056a Translated using Weblate (Portuguese)
Currently translated at 100.0% (498 of 498 strings)

Co-authored-by: Paulo Oliveira <junior.literasas@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/pt/
Translation: Kotatsu/Strings
2023-10-28 16:16:39 +03:00
Koitharu
d9acc4ec18 Fix periodical backups to external directory 2023-10-28 16:14:47 +03:00
Koitharu
577cc848ee Scroll lists to top atomatically 2023-10-28 15:26:22 +03:00
Koitharu
8a64c88a07 (Temporary) remove chapters list from downloads 2023-10-28 14:44:58 +03:00
Koitharu
1cd7745e38 Update parsers 2023-10-28 13:26:02 +03:00
Koitharu
395b3f7200 Fix proguard rules 2023-10-27 17:27:40 +03:00
Koitharu
b8db4c81d8 Handle up navigation from reader 2023-10-27 16:44:40 +03:00
Koitharu
98bd42f3ae Remove deletions from sync process 2023-10-27 15:02:10 +03:00
Koitharu
db8835a7b8 Fix history restoring 2023-10-27 14:18:14 +03:00
Koitharu
afe50a9ed6 Fixes 2023-10-27 13:58:04 +03:00
Koitharu
beba818f57 Periodic backups 2023-10-26 17:24:11 +03:00
Koitharu
beb17ef442 Pause autoscroll while touch down 2023-10-26 16:13:30 +03:00
43 changed files with 615 additions and 221 deletions

View File

@@ -16,8 +16,8 @@ android {
applicationId 'org.koitharu.kotatsu' applicationId 'org.koitharu.kotatsu'
minSdk = 21 minSdk = 21
targetSdk = 34 targetSdk = 34
versionCode = 591 versionCode = 593
versionName = '6.2.4' versionName = '6.2.5'
generatedDensities = [] generatedDensities = []
testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner" testInstrumentationRunner "org.koitharu.kotatsu.HiltTestRunner"
ksp { ksp {
@@ -82,7 +82,7 @@ afterEvaluate {
} }
dependencies { dependencies {
//noinspection GradleDependency //noinspection GradleDependency
implementation('com.github.KotatsuApp:kotatsu-parsers:af1aca8725') { implementation('com.github.KotatsuApp:kotatsu-parsers:4ca3a492b0') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }

View File

@@ -18,3 +18,4 @@
-keep class org.koitharu.kotatsu.core.exceptions.* { *; } -keep class org.koitharu.kotatsu.core.exceptions.* { *; }
-keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment -keep class org.koitharu.kotatsu.settings.NotificationSettingsLegacyFragment
-keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; } -keep class org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy { *; }
-keep class org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment { *; }

View File

@@ -29,7 +29,7 @@ class BackupZipOutput(val file: File) : Closeable {
} }
} }
private const val DIR_BACKUPS = "backups" const val DIR_BACKUPS = "backups"
suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) { suspend fun BackupZipOutput(context: Context): BackupZipOutput = runInterruptible(Dispatchers.IO) {
val dir = context.run { val dir = context.run {

View File

@@ -354,6 +354,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
val is32BitColorsEnabled: Boolean val is32BitColorsEnabled: Boolean
get() = prefs.getBoolean(KEY_32BIT_COLOR, false) get() = prefs.getBoolean(KEY_32BIT_COLOR, false)
val isPeriodicalBackupEnabled: Boolean
get() = prefs.getBoolean(KEY_BACKUP_PERIODICAL_ENABLED, false)
val periodicalBackupFrequency: Long
get() = prefs.getString(KEY_BACKUP_PERIODICAL_FREQUENCY, null)?.toLongOrNull() ?: 7L
var periodicalBackupOutput: Uri?
get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull()
set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) }
fun isTipEnabled(tip: String): Boolean { fun isTipEnabled(tip: String): Boolean {
return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true return prefs.getStringSet(KEY_TIPS_CLOSED, emptySet())?.contains(tip) != true
} }
@@ -458,6 +468,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
const val KEY_ZOOM_MODE = "zoom_mode" const val KEY_ZOOM_MODE = "zoom_mode"
const val KEY_BACKUP = "backup" const val KEY_BACKUP = "backup"
const val KEY_RESTORE = "restore" const val KEY_RESTORE = "restore"
const val KEY_BACKUP_PERIODICAL_ENABLED = "backup_periodic"
const val KEY_BACKUP_PERIODICAL_FREQUENCY = "backup_periodic_freq"
const val KEY_BACKUP_PERIODICAL_OUTPUT = "backup_periodic_output"
const val KEY_BACKUP_PERIODICAL_LAST = "backup_periodic_last"
const val KEY_HISTORY_GROUPING = "history_grouping" const val KEY_HISTORY_GROUPING = "history_grouping"
const val KEY_READING_INDICATORS = "reading_indicators" const val KEY_READING_INDICATORS = "reading_indicators"
const val KEY_REVERSE_CHAPTERS = "reverse_chapters" const val KEY_REVERSE_CHAPTERS = "reverse_chapters"

View File

@@ -6,7 +6,6 @@ import android.graphics.Color
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.view.KeyEvent import android.view.KeyEvent
import android.view.MenuItem
import android.view.View import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.annotation.CallSuper import androidx.annotation.CallSuper
@@ -96,11 +95,10 @@ abstract class BaseActivity<B : ViewBinding> :
insetsDelegate.onViewCreated(binding.root) insetsDelegate.onViewCreated(binding.root)
} }
override fun onOptionsItemSelected(item: MenuItem) = if (item.itemId == android.R.id.home) { override fun onSupportNavigateUp(): Boolean {
onBackPressedDispatcher.onBackPressed() dispatchNavigateUp()
// TODO: navigateUpTo return true
true }
} else super.onOptionsItemSelected(item)
override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean {
if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) { if (BuildConfig.DEBUG && keyCode == KeyEvent.KEYCODE_VOLUME_UP) {
@@ -151,6 +149,17 @@ abstract class BaseActivity<B : ViewBinding> :
window.statusBarColor = defaultStatusBarColor window.statusBarColor = defaultStatusBarColor
} }
protected open fun dispatchNavigateUp() {
val upIntent = parentActivityIntent
if (upIntent != null) {
if (!navigateUpTo(upIntent)) {
startActivity(upIntent)
}
} else {
finishAfterTransition()
}
}
private fun putDataToExtras(intent: Intent?) { private fun putDataToExtras(intent: Intent?) {
intent?.putExtra(EXTRA_DATA, intent.data) intent?.putExtra(EXTRA_DATA, intent.data)
} }

View File

@@ -0,0 +1,40 @@
package org.koitharu.kotatsu.core.ui.list
import androidx.recyclerview.widget.LinearLayoutManager
import androidx.recyclerview.widget.RecyclerView
import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver
class RecyclerScrollKeeper(
private val rv: RecyclerView,
) : AdapterDataObserver() {
private val scrollUpRunnable = Runnable {
(rv.layoutManager as? LinearLayoutManager)?.scrollToPositionWithOffset(0, 0)
}
fun attach() {
rv.adapter?.registerAdapterDataObserver(this)
}
override fun onItemRangeInserted(positionStart: Int, itemCount: Int) {
super.onItemRangeInserted(positionStart, itemCount)
if (positionStart == 0 && isScrolledToTop()) {
postScrollUp()
}
}
override fun onItemRangeMoved(fromPosition: Int, toPosition: Int, itemCount: Int) {
super.onItemRangeMoved(fromPosition, toPosition, itemCount)
if (toPosition == 0 && isScrolledToTop()) {
postScrollUp()
}
}
private fun postScrollUp() {
rv.postDelayed(scrollUpRunnable, 500L)
}
private fun isScrolledToTop(): Boolean {
return (rv.layoutManager as? LinearLayoutManager)?.findFirstVisibleItemPosition() == 0
}
}

View File

@@ -83,7 +83,7 @@ fun <I> ActivityResultLauncher<I>.tryLaunch(
e.printStackTraceDebug() e.printStackTraceDebug()
}.isSuccess }.isSuccess
fun SharedPreferences.observe() = callbackFlow<String?> { fun SharedPreferences.observe(): Flow<String?> = callbackFlow {
val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key ->
trySendBlocking(key) trySendBlocking(key)
} }

View File

@@ -23,7 +23,7 @@ fun ImageView.newImageRequest(lifecycleOwner: LifecycleOwner, data: Any?): Image
return null return null
} }
} }
disposeImageRequest() // disposeImageRequest()
return ImageRequest.Builder(context) return ImageRequest.Builder(context)
.data(data) .data(data)
.lifecycle(lifecycleOwner) .lifecycle(lifecycleOwner)

View File

@@ -87,5 +87,5 @@ class DetailsInteractor @Inject constructor(
} }
} }
suspend fun findLocal(seed: Manga) = localMangaRepository.getRemoteManga(seed) suspend fun findRemote(seed: Manga) = localMangaRepository.getRemoteManga(seed)
} }

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.details.domain
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.model.findChapter import org.koitharu.kotatsu.core.model.findChapter
import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.os.NetworkState
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.history.data.PROGRESS_NONE import org.koitharu.kotatsu.history.data.PROGRESS_NONE
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
@@ -13,6 +14,7 @@ class ProgressUpdateUseCase @Inject constructor(
private val mangaRepositoryFactory: MangaRepository.Factory, private val mangaRepositoryFactory: MangaRepository.Factory,
private val database: MangaDatabase, private val database: MangaDatabase,
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val networkState: NetworkState,
) { ) {
suspend operator fun invoke(manga: Manga): Float { suspend operator fun invoke(manga: Manga): Float {
@@ -22,6 +24,9 @@ class ProgressUpdateUseCase @Inject constructor(
} else { } else {
manga manga
} }
if (!seed.isLocal && !networkState.value) {
return PROGRESS_NONE
}
val repo = mangaRepositoryFactory.create(seed.source) val repo = mangaRepositoryFactory.create(seed.source)
val details = if (manga.source != seed.source || seed.chapters.isNullOrEmpty()) { val details = if (manga.source != seed.source || seed.chapters.isNullOrEmpty()) {
repo.getDetails(seed) repo.getDetails(seed)

View File

@@ -149,15 +149,13 @@ class DetailsViewModel @Inject constructor(
val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId) val scrobblingInfo: StateFlow<List<ScrobblingInfo>> = interactor.observeScrobblingInfo(mangaId)
.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
val relatedManga: StateFlow<List<MangaItemModel>> = manga val relatedManga: StateFlow<List<MangaItemModel>> = manga.mapLatest {
.mapLatest { if (it != null && settings.isRelatedMangaEnabled) {
if (it != null && settings.isRelatedMangaEnabled) { relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty()
relatedMangaUseCase.invoke(it)?.toUi(ListMode.GRID, extraProvider).orEmpty() } else {
} else { emptyList()
emptyList()
}
} }
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val branches: StateFlow<List<MangaBranch>> = combine( val branches: StateFlow<List<MangaBranch>> = combine(
details, details,
@@ -217,7 +215,7 @@ class DetailsViewModel @Inject constructor(
} }
launchJob(Dispatchers.Default) { launchJob(Dispatchers.Default) {
val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob val manga = details.firstOrNull { it != null && it.isLocal } ?: return@launchJob
remoteManga.value = interactor.findLocal(manga.toManga()) remoteManga.value = interactor.findRemote(manga.toManga())
} }
} }

View File

@@ -18,8 +18,7 @@ data class DownloadState(
val currentPage: Int = 0, val currentPage: Int = 0,
val eta: Long = -1L, val eta: Long = -1L,
val localManga: LocalManga? = null, val localManga: LocalManga? = null,
val downloadedChapters: LongArray = LongArray(0), val downloadedChapters: Int = 0,
val scheduledChapters: LongArray = LongArray(0),
val timestamp: Long = System.currentTimeMillis(), val timestamp: Long = System.currentTimeMillis(),
) { ) {
@@ -42,68 +41,17 @@ data class DownloadState(
.putLong(DATA_ETA, eta) .putLong(DATA_ETA, eta)
.putLong(DATA_TIMESTAMP, timestamp) .putLong(DATA_TIMESTAMP, timestamp)
.putString(DATA_ERROR, error) .putString(DATA_ERROR, error)
.putLongArray(DATA_CHAPTERS, downloadedChapters) .putInt(DATA_CHAPTERS, downloadedChapters)
.putLongArray(DATA_CHAPTERS_SRC, scheduledChapters)
.putBoolean(DATA_INDETERMINATE, isIndeterminate) .putBoolean(DATA_INDETERMINATE, isIndeterminate)
.putBoolean(DATA_PAUSED, isPaused) .putBoolean(DATA_PAUSED, isPaused)
.build() .build()
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as DownloadState
if (manga != other.manga) return false
if (isIndeterminate != other.isIndeterminate) return false
if (isPaused != other.isPaused) return false
if (isStopped != other.isStopped) return false
if (error != other.error) return false
if (totalChapters != other.totalChapters) return false
if (currentChapter != other.currentChapter) return false
if (totalPages != other.totalPages) return false
if (currentPage != other.currentPage) return false
if (eta != other.eta) return false
if (localManga != other.localManga) return false
if (!downloadedChapters.contentEquals(other.downloadedChapters)) return false
if (!scheduledChapters.contentEquals(other.scheduledChapters)) return false
if (timestamp != other.timestamp) return false
if (max != other.max) return false
if (progress != other.progress) return false
if (percent != other.percent) return false
return true
}
override fun hashCode(): Int {
var result = manga.hashCode()
result = 31 * result + isIndeterminate.hashCode()
result = 31 * result + isPaused.hashCode()
result = 31 * result + isStopped.hashCode()
result = 31 * result + (error?.hashCode() ?: 0)
result = 31 * result + totalChapters
result = 31 * result + currentChapter
result = 31 * result + totalPages
result = 31 * result + currentPage
result = 31 * result + eta.hashCode()
result = 31 * result + (localManga?.hashCode() ?: 0)
result = 31 * result + downloadedChapters.contentHashCode()
result = 31 * result + scheduledChapters.contentHashCode()
result = 31 * result + timestamp.hashCode()
result = 31 * result + max
result = 31 * result + progress
result = 31 * result + percent.hashCode()
return result
}
companion object { companion object {
private const val DATA_MANGA_ID = "manga_id" private const val DATA_MANGA_ID = "manga_id"
private const val DATA_MAX = "max" private const val DATA_MAX = "max"
private const val DATA_PROGRESS = "progress" private const val DATA_PROGRESS = "progress"
private const val DATA_CHAPTERS = "chapter" private const val DATA_CHAPTERS = "chapter_cnt"
private const val DATA_CHAPTERS_SRC = "chapters_src"
private const val DATA_ETA = "eta" private const val DATA_ETA = "eta"
private const val DATA_TIMESTAMP = "timestamp" private const val DATA_TIMESTAMP = "timestamp"
private const val DATA_ERROR = "error" private const val DATA_ERROR = "error"
@@ -126,8 +74,6 @@ data class DownloadState(
fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L)) fun getTimestamp(data: Data): Date = Date(data.getLong(DATA_TIMESTAMP, 0L))
fun getDownloadedChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS) ?: LongArray(0) fun getDownloadedChapters(data: Data): Int = data.getInt(DATA_CHAPTERS, 0)
fun getScheduledChapters(data: Data): LongArray = data.getLongArray(DATA_CHAPTERS_SRC) ?: LongArray(0)
} }
} }

View File

@@ -2,28 +2,22 @@ package org.koitharu.kotatsu.download.ui.list
import android.transition.TransitionManager import android.transition.TransitionManager
import android.view.View import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.RecyclerView
import androidx.work.WorkInfo import androidx.work.WorkInfo
import coil.ImageLoader import coil.ImageLoader
import coil.request.SuccessResult
import coil.util.CoilUtils
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.util.ext.drawableEnd
import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.source import org.koitharu.kotatsu.core.util.ext.source
import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.ItemDownloadBinding import org.koitharu.kotatsu.databinding.ItemDownloadBinding
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.list.chapters.downloadChapterAD
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.format
@@ -36,9 +30,7 @@ fun downloadItemAD(
) { ) {
val percentPattern = context.resources.getString(R.string.percent_string_pattern) val percentPattern = context.resources.getString(R.string.percent_string_pattern)
val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse) // val expandIcon = ContextCompat.getDrawable(context, R.drawable.ic_expand_collapse)
val chaptersAdapter = BaseListAdapter<DownloadChapter>()
.addDelegate(ListItemType.CHAPTER, downloadChapterAD())
val clickListener = object : View.OnClickListener, View.OnLongClickListener { val clickListener = object : View.OnClickListener, View.OnLongClickListener {
override fun onClick(v: View) { override fun onClick(v: View) {
@@ -59,27 +51,26 @@ fun downloadItemAD(
binding.buttonResume.setOnClickListener(clickListener) binding.buttonResume.setOnClickListener(clickListener)
itemView.setOnClickListener(clickListener) itemView.setOnClickListener(clickListener)
itemView.setOnLongClickListener(clickListener) itemView.setOnLongClickListener(clickListener)
binding.recyclerViewChapters.addItemDecoration(DividerItemDecoration(context, RecyclerView.VERTICAL))
binding.recyclerViewChapters.adapter = chaptersAdapter
bind { payloads -> bind { payloads ->
if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) { if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads && context.isAnimationsEnabled) {
TransitionManager.beginDelayedTransition(binding.constraintLayout) TransitionManager.beginDelayedTransition(binding.constraintLayout)
} }
binding.textViewTitle.text = item.manga.title binding.textViewTitle.text = item.manga.title
binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply { if ((CoilUtils.result(binding.imageViewCover) as? SuccessResult)?.memoryCacheKey != item.coverCacheKey) {
placeholder(R.drawable.ic_placeholder) binding.imageViewCover.newImageRequest(lifecycleOwner, item.manga.coverUrl)?.apply {
fallback(R.drawable.ic_placeholder) placeholder(R.drawable.ic_placeholder)
error(R.drawable.ic_error_placeholder) fallback(R.drawable.ic_placeholder)
allowRgb565(true) error(R.drawable.ic_error_placeholder)
transformations(TrimTransformation()) allowRgb565(true)
source(item.manga.source) transformations(TrimTransformation())
enqueueWith(coil) memoryCacheKey(item.coverCacheKey)
source(item.manga.source)
enqueueWith(coil)
}
} }
binding.textViewTitle.isChecked = item.isExpanded // binding.textViewTitle.isChecked = item.isExpanded
binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null // binding.textViewTitle.drawableEnd = if (item.isExpandable) expandIcon else null
binding.cardDetails.isVisible = item.isExpanded
chaptersAdapter.items = item.chapters
when (item.workState) { when (item.workState) {
WorkInfo.State.ENQUEUED, WorkInfo.State.ENQUEUED,
WorkInfo.State.BLOCKED -> { WorkInfo.State.BLOCKED -> {
@@ -117,11 +108,11 @@ fun downloadItemAD(
binding.progressBar.isVisible = false binding.progressBar.isVisible = false
binding.progressBar.isEnabled = true binding.progressBar.isEnabled = true
binding.textViewPercent.isVisible = false binding.textViewPercent.isVisible = false
if (item.totalChapters > 0) { if (item.chaptersDownloaded > 0) {
binding.textViewDetails.text = context.resources.getQuantityString( binding.textViewDetails.text = context.resources.getQuantityString(
R.plurals.chapters, R.plurals.chapters,
item.totalChapters, item.chaptersDownloaded,
item.totalChapters, item.chaptersDownloaded,
) )
binding.textViewDetails.isVisible = true binding.textViewDetails.isVisible = true
} else { } else {

View File

@@ -2,7 +2,7 @@ package org.koitharu.kotatsu.download.ui.list
import android.text.format.DateUtils import android.text.format.DateUtils
import androidx.work.WorkInfo import androidx.work.WorkInfo
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter import coil.memory.MemoryCache
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
@@ -17,14 +17,15 @@ data class DownloadItemModel(
val manga: Manga, val manga: Manga,
val error: String?, val error: String?,
val max: Int, val max: Int,
val totalChapters: Int,
val progress: Int, val progress: Int,
val eta: Long, val eta: Long,
val timestamp: Date, val timestamp: Date,
val chapters: List<DownloadChapter>, val chaptersDownloaded: Int,
val isExpanded: Boolean, val isExpanded: Boolean,
) : ListModel, Comparable<DownloadItemModel> { ) : ListModel, Comparable<DownloadItemModel> {
val coverCacheKey = MemoryCache.Key(manga.coverUrl, mapOf("dl" to "1"))
val percent: Float val percent: Float
get() = if (max > 0) progress / max.toFloat() else 0f get() = if (max > 0) progress / max.toFloat() else 0f
@@ -38,7 +39,7 @@ data class DownloadItemModel(
get() = workState == WorkInfo.State.RUNNING && isPaused get() = workState == WorkInfo.State.RUNNING && isPaused
val isExpandable: Boolean val isExpandable: Boolean
get() = chapters.isNotEmpty() get() = false // TODO
fun getEtaString(): CharSequence? = if (hasEta) { fun getEtaString(): CharSequence? = if (hasEta) {
DateUtils.getRelativeTimeSpanString( DateUtils.getRelativeTimeSpanString(

View File

@@ -15,6 +15,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
@@ -53,6 +54,7 @@ class DownloadsActivity : BaseActivity<ActivityDownloadsBinding>(),
addItemDecoration(decoration) addItemDecoration(decoration)
adapter = downloadsAdapter adapter = downloadsAdapter
selectionController.attachToRecyclerView(this) selectionController.attachToRecyclerView(this)
RecyclerScrollKeeper(this).attach()
} }
addMenuProvider(DownloadsMenuProvider(this, viewModel)) addMenuProvider(DownloadsMenuProvider(this, viewModel))
viewModel.items.observe(this) { viewModel.items.observe(this) {

View File

@@ -28,7 +28,6 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.daysDiff import org.koitharu.kotatsu.core.util.ext.daysDiff
import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.chapters.DownloadChapter
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListHeader
@@ -239,8 +238,6 @@ class DownloadsViewModel @Inject constructor(
val mangaId = DownloadState.getMangaId(workData) val mangaId = DownloadState.getMangaId(workData)
if (mangaId == 0L) return null if (mangaId == 0L) return null
val manga = getManga(mangaId) ?: return null val manga = getManga(mangaId) ?: return null
val downloadedChapters = DownloadState.getDownloadedChapters(workData)
val scheduledChapters = DownloadState.getScheduledChapters(workData).toSet()
return DownloadItemModel( return DownloadItemModel(
id = id, id = id,
workState = state, workState = state,
@@ -252,19 +249,8 @@ class DownloadsViewModel @Inject constructor(
progress = DownloadState.getProgress(workData), progress = DownloadState.getProgress(workData),
eta = DownloadState.getEta(workData), eta = DownloadState.getEta(workData),
timestamp = DownloadState.getTimestamp(workData), timestamp = DownloadState.getTimestamp(workData),
totalChapters = downloadedChapters.size, chaptersDownloaded = DownloadState.getDownloadedChapters(workData),
isExpanded = isExpanded, isExpanded = isExpanded,
chapters = manga.chapters?.mapNotNull {
if (it.id in scheduledChapters) {
DownloadChapter(
number = it.number,
name = it.name,
isDownloaded = it.id in downloadedChapters,
)
} else {
null
}
}.orEmpty(),
) )
} }

View File

@@ -38,6 +38,7 @@ import okio.buffer
import okio.sink import okio.sink
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions import org.koitharu.kotatsu.core.exceptions.TooManyRequestExceptions
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.CommonHeaders
import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository
@@ -46,7 +47,6 @@ import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.Throttler import org.koitharu.kotatsu.core.util.Throttler
import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork
import org.koitharu.kotatsu.core.util.ext.awaitWorkInfoById
import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.deleteWork import org.koitharu.kotatsu.core.util.ext.deleteWork
@@ -105,11 +105,12 @@ class DownloadWorker @AssistedInject constructor(
setForeground(getForegroundInfo()) setForeground(getForegroundInfo())
val mangaId = inputData.getLong(MANGA_ID, 0L) val mangaId = inputData.getLong(MANGA_ID, 0L)
val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure() val manga = mangaDataRepository.findMangaById(mangaId) ?: return Result.failure()
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters()
lastPublishedState = DownloadState(manga, isIndeterminate = true) lastPublishedState = DownloadState(manga, isIndeterminate = true)
publishState(DownloadState(manga, isIndeterminate = true))
val chaptersIds = inputData.getLongArray(CHAPTERS_IDS)?.takeUnless { it.isEmpty() }
val downloadedIds = getDoneChapters(manga)
return try { return try {
downloadMangaImpl(chaptersIds, downloadedIds) downloadMangaImpl(manga, chaptersIds, downloadedIds)
Result.success(currentState.toWorkData()) Result.success(currentState.toWorkData())
} catch (e: CancellationException) { } catch (e: CancellationException) {
withContext(NonCancellable) { withContext(NonCancellable) {
@@ -147,10 +148,11 @@ class DownloadWorker @AssistedInject constructor(
} }
private suspend fun downloadMangaImpl( private suspend fun downloadMangaImpl(
subject: Manga,
includedIds: LongArray?, includedIds: LongArray?,
excludedIds: LongArray, excludedIds: Set<Long>,
) { ) {
var manga = currentState.manga var manga = subject
val chaptersToSkip = excludedIds.toMutableSet() val chaptersToSkip = excludedIds.toMutableSet()
withMangaLock(manga) { withMangaLock(manga) {
ContextCompat.registerReceiver( ContextCompat.registerReceiver(
@@ -178,16 +180,9 @@ class DownloadWorker @AssistedInject constructor(
} }
} }
val chapters = getChapters(mangaDetails, includedIds) val chapters = getChapters(mangaDetails, includedIds)
publishState(
currentState.copy(scheduledChapters = LongArray(chapters.size) { i -> chapters[i].id }),
)
for ((chapterIndex, chapter) in chapters.withIndex()) { for ((chapterIndex, chapter) in chapters.withIndex()) {
if (chaptersToSkip.remove(chapter.id)) { if (chaptersToSkip.remove(chapter.id)) {
publishState( publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
continue continue
} }
val pages = runFailsafe(pausingHandle) { val pages = runFailsafe(pausingHandle) {
@@ -225,11 +220,7 @@ class DownloadWorker @AssistedInject constructor(
localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga()) localStorageChanges.emit(LocalMangaInput.of(output.rootFile).getManga())
}.onFailure(Throwable::printStackTraceDebug) }.onFailure(Throwable::printStackTraceDebug)
} }
publishState( publishState(currentState.copy(downloadedChapters = currentState.downloadedChapters + 1))
currentState.copy(
downloadedChapters = currentState.downloadedChapters + chapter.id,
),
)
} }
publishState(currentState.copy(isIndeterminate = true, eta = -1L)) publishState(currentState.copy(isIndeterminate = true, eta = -1L))
output.mergeWithExisting() output.mergeWithExisting()
@@ -336,11 +327,9 @@ class DownloadWorker @AssistedInject constructor(
setProgress(state.toWorkData()) setProgress(state.toWorkData())
} }
private suspend fun getDoneChapters(): LongArray { private suspend fun getDoneChapters(manga: Manga) = runCatchingCancellable {
val work = WorkManager.getInstance(applicationContext).awaitWorkInfoById(id) localMangaRepository.getDetails(manga).chapters?.ids()
?: return LongArray(0) }.getOrNull().orEmpty()
return DownloadState.getDownloadedChapters(work.progress)
}
private fun getChapters( private fun getChapters(
manga: Manga, manga: Manga,

View File

@@ -169,6 +169,7 @@ abstract class FavouritesDao {
ListSortOrder.NEWEST -> "favourites.created_at DESC" ListSortOrder.NEWEST -> "favourites.created_at DESC"
ListSortOrder.ALPHABETIC -> "manga.title ASC" ListSortOrder.ALPHABETIC -> "manga.title ASC"
ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC" ListSortOrder.NEW_CHAPTERS -> "(SELECT chapters_new FROM tracks WHERE tracks.manga_id = manga.manga_id) DESC"
ListSortOrder.UPDATED, // for legacy support
ListSortOrder.PROGRESS -> "(SELECT percent FROM history WHERE history.manga_id = manga.manga_id) DESC" ListSortOrder.PROGRESS -> "(SELECT percent FROM history WHERE history.manga_id = manga.manga_id) DESC"
else -> throw IllegalArgumentException("Sort order $sortOrder is not supported") else -> throw IllegalArgumentException("Sort order $sortOrder is not supported")
} }

View File

@@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.db.entity.toMangaTag
import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.db.entity.toMangaTags
import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.core.model.MangaHistory
import org.koitharu.kotatsu.core.model.findById import org.koitharu.kotatsu.core.model.findById
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.util.ReversibleHandle import org.koitharu.kotatsu.core.ui.util.ReversibleHandle
import org.koitharu.kotatsu.core.util.ext.mapItems import org.koitharu.kotatsu.core.util.ext.mapItems
@@ -185,7 +186,7 @@ class HistoryRepository @Inject constructor(
private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity { private suspend fun HistoryEntity.recoverIfNeeded(manga: Manga): HistoryEntity {
val chapters = manga.chapters val chapters = manga.chapters
if (chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) { if (manga.isLocal || chapters.isNullOrEmpty() || chapters.findById(chapterId) != null) {
return this return this
} }
val newChapterId = chapters.getOrNull( val newChapterId = chapters.getOrNull(

View File

@@ -9,6 +9,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.os.NetworkManageIntent import org.koitharu.kotatsu.core.os.NetworkManageIntent
import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.addMenuProvider
import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.list.ui.MangaListFragment
@@ -23,6 +24,7 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
RecyclerScrollKeeper(binding.recyclerView).attach()
addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel)) addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
} }

View File

@@ -183,7 +183,8 @@ class MainActivity : BaseActivity<ActivityMainBinding>(), AppBarOwner, BottomNav
override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) {
android.R.id.home -> if (isSearchOpened()) { android.R.id.home -> if (isSearchOpened()) {
super.onOptionsItemSelected(item) closeSearchCallback.handleOnBackPressed()
true
} else { } else {
viewBinding.searchView.requestFocus() viewBinding.searchView.requestFocus()
true true

View File

@@ -52,6 +52,7 @@ import org.koitharu.kotatsu.core.util.ext.postDelayed
import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.zipWithPrevious import org.koitharu.kotatsu.core.util.ext.zipWithPrevious
import org.koitharu.kotatsu.databinding.ActivityReaderBinding import org.koitharu.kotatsu.databinding.ActivityReaderBinding
import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet
@@ -147,6 +148,11 @@ class ReaderActivity :
} }
} }
override fun getParentActivityIntent(): Intent? {
val manga = viewModel.manga?.toManga() ?: return null
return DetailsActivity.newIntent(this, manga)
}
override fun onUserInteraction() { override fun onUserInteraction() {
super.onUserInteraction() super.onUserInteraction()
scrollTimer.onUserInteraction() scrollTimer.onUserInteraction()
@@ -249,6 +255,7 @@ class ReaderActivity :
override fun dispatchTouchEvent(ev: MotionEvent): Boolean { override fun dispatchTouchEvent(ev: MotionEvent): Boolean {
touchHelper.dispatchTouchEvent(ev) touchHelper.dispatchTouchEvent(ev)
scrollTimer.onTouchEvent(ev)
return super.dispatchTouchEvent(ev) return super.dispatchTouchEvent(ev)
} }

View File

@@ -1,5 +1,6 @@
package org.koitharu.kotatsu.reader.ui package org.koitharu.kotatsu.reader.ui
import android.view.MotionEvent
import androidx.lifecycle.LifecycleOwner import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import dagger.assisted.Assisted import dagger.assisted.Assisted
@@ -8,11 +9,14 @@ import dagger.assisted.AssistedInject
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.yield
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsFlow
import kotlin.math.roundToLong import kotlin.math.roundToLong
@@ -33,6 +37,7 @@ class ScrollTimer @AssistedInject constructor(
private var delayMs: Long = 10L private var delayMs: Long = 10L
private var pageSwitchDelay: Long = 100L private var pageSwitchDelay: Long = 100L
private var resumeAt = 0L private var resumeAt = 0L
private var isTouchDown = MutableStateFlow(false)
var isEnabled: Boolean = false var isEnabled: Boolean = false
set(value) { set(value) {
@@ -55,6 +60,19 @@ class ScrollTimer @AssistedInject constructor(
resumeAt = System.currentTimeMillis() + INTERACTION_SKIP_MS resumeAt = System.currentTimeMillis() + INTERACTION_SKIP_MS
} }
fun onTouchEvent(event: MotionEvent) {
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
isTouchDown.value = true
}
MotionEvent.ACTION_UP,
MotionEvent.ACTION_CANCEL -> {
isTouchDown.value = false
}
}
}
private fun onSpeedChanged(speed: Float) { private fun onSpeedChanged(speed: Float) {
if (speed <= 0f) { if (speed <= 0f) {
delayMs = 0L delayMs = 0L
@@ -108,12 +126,18 @@ class ScrollTimer @AssistedInject constructor(
} }
private fun isPaused(): Boolean { private fun isPaused(): Boolean {
return resumeAt > System.currentTimeMillis() return isTouchDown.value || resumeAt > System.currentTimeMillis()
} }
private suspend fun delayUntilResumed() { private suspend fun delayUntilResumed() {
while (isPaused()) { while (isPaused()) {
delay(resumeAt - System.currentTimeMillis()) val delayTime = resumeAt - System.currentTimeMillis()
if (delayTime > 0) {
delay(delayTime)
} else {
yield()
}
isTouchDown.first { !it }
} }
} }

View File

@@ -43,7 +43,6 @@ class BackupViewModel @Inject constructor(
backup.finish() backup.finish()
progress.value = 1f progress.value = 1f
backup.close()
backup.file backup.file
} }
onBackupDone.call(file) onBackupDone.call(file)

View File

@@ -0,0 +1,98 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.os.Bundle
import android.view.View
import androidx.activity.result.ActivityResultCallback
import androidx.activity.result.contract.ActivityResultContracts
import androidx.documentfile.provider.DocumentFile
import androidx.preference.Preference
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.backup.DIR_BACKUPS
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BasePreferenceFragment
import org.koitharu.kotatsu.core.util.ext.resolveFile
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import java.io.File
import java.text.SimpleDateFormat
import javax.inject.Inject
@AndroidEntryPoint
class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodic_backups),
ActivityResultCallback<Uri?> {
@Inject
lateinit var scheduler: PeriodicalBackupWorker.Scheduler
private val outputSelectCall = registerForActivityResult(
ActivityResultContracts.OpenDocumentTree(),
this,
)
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
addPreferencesFromResource(R.xml.pref_backup_periodic)
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
bindOutputSummary()
bindLastBackupInfo()
}
override fun onPreferenceTreeClick(preference: Preference): Boolean {
return when (preference.key) {
AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null)
else -> super.onPreferenceTreeClick(preference)
}
}
override fun onActivityResult(result: Uri?) {
if (result != null) {
val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION
context?.contentResolver?.takePersistableUriPermission(result, takeFlags)
settings.periodicalBackupOutput = result
bindOutputSummary()
}
}
private fun bindOutputSummary() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return
viewLifecycleScope.launch {
preference.summary = withContext(Dispatchers.Default) {
val value = settings.periodicalBackupOutput
value?.toUserFriendlyString(preference.context) ?: preference.context.run {
getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS)
}.path
}
}
}
private fun bindLastBackupInfo() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return
viewLifecycleScope.launch {
val lastDate = withContext(Dispatchers.Default) {
scheduler.getLastSuccessfulBackup()
}
preference.summary = lastDate?.let {
val format = SimpleDateFormat.getDateTimeInstance(SimpleDateFormat.MEDIUM, SimpleDateFormat.SHORT)
preference.context.getString(R.string.last_successful_backup, format.format(it))
}
preference.isVisible = lastDate != null
}
}
private fun Uri.toUserFriendlyString(context: Context): String {
val df = DocumentFile.fromTreeUri(context, this)
if (df?.canWrite() != true) {
return context.getString(R.string.invalid_value_message)
}
return resolveFile(context)?.path ?: toString()
}
}

View File

@@ -0,0 +1,116 @@
package org.koitharu.kotatsu.settings.backup
import android.content.Context
import android.os.Build
import androidx.documentfile.provider.DocumentFile
import androidx.hilt.work.HiltWorker
import androidx.work.Constraints
import androidx.work.CoroutineWorker
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkInfo
import androidx.work.WorkManager
import androidx.work.WorkerParameters
import androidx.work.await
import androidx.work.workDataOf
import dagger.Reusable
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import okio.buffer
import okio.sink
import okio.source
import org.koitharu.kotatsu.core.backup.BackupRepository
import org.koitharu.kotatsu.core.backup.BackupZipOutput
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler
import java.util.Date
import java.util.concurrent.TimeUnit
import javax.inject.Inject
@HiltWorker
class PeriodicalBackupWorker @AssistedInject constructor(
@Assisted appContext: Context,
@Assisted params: WorkerParameters,
private val repository: BackupRepository,
private val settings: AppSettings,
) : CoroutineWorker(appContext, params) {
override suspend fun doWork(): Result {
val resultData = workDataOf(DATA_TIMESTAMP to Date().time)
val file = BackupZipOutput(applicationContext).use { backup ->
backup.put(repository.createIndex())
backup.put(repository.dumpHistory())
backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites())
backup.put(repository.dumpBookmarks())
backup.put(repository.dumpSettings())
backup.finish()
backup.file
}
val dirUri = settings.periodicalBackupOutput ?: return Result.success(resultData)
val target = DocumentFile.fromTreeUri(applicationContext, dirUri)
?.createFile("application/zip", file.name)
?.uri ?: return Result.failure()
applicationContext.contentResolver.openOutputStream(target)?.use { output ->
file.source().use { input ->
output.sink().buffer().writeAllCancellable(input)
}
} ?: return Result.failure()
file.deleteAwait()
return Result.success(resultData)
}
@Reusable
class Scheduler @Inject constructor(
private val workManager: WorkManager,
private val settings: AppSettings,
) : PeriodicWorkScheduler {
override suspend fun schedule() {
val constraints = Constraints.Builder()
.setRequiresStorageNotLow(true)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
constraints.setRequiresDeviceIdle(true)
}
val request = PeriodicWorkRequestBuilder<PeriodicalBackupWorker>(
settings.periodicalBackupFrequency,
TimeUnit.DAYS,
).setConstraints(constraints.build())
.addTag(TAG)
.build()
workManager
.enqueueUniquePeriodicWork(TAG, ExistingPeriodicWorkPolicy.UPDATE, request)
.await()
}
override suspend fun unschedule() {
workManager
.cancelUniqueWork(TAG)
.await()
}
override suspend fun isScheduled(): Boolean {
return workManager
.awaitUniqueWorkInfoByName(TAG)
.any { !it.state.isFinished }
}
suspend fun getLastSuccessfulBackup(): Date? {
return workManager
.awaitUniqueWorkInfoByName(TAG)
.lastOrNull { x -> x.state == WorkInfo.State.SUCCEEDED }
?.outputData
?.getLong(DATA_TIMESTAMP, 0)
?.let { if (it != 0L) Date(it) else null }
}
}
private companion object {
const val TAG = "backups"
const val DATA_TIMESTAMP = "ts"
}
}

View File

@@ -65,6 +65,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) findPreference<Preference>(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES]))
findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) findPreference<Preference>(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS]))
findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) findPreference<Preference>(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize)
bindPeriodicalBackupSummary()
findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> findPreference<Preference>(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref ->
viewModel.searchHistoryCount.observe(viewLifecycleOwner) { viewModel.searchHistoryCount.observe(viewLifecycleOwner) {
pref.summary = if (it < 0) { pref.summary = if (it < 0) {
@@ -200,6 +201,20 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac
} }
} }
private fun bindPeriodicalBackupSummary() {
val preference = findPreference<Preference>(AppSettings.KEY_BACKUP_PERIODICAL_ENABLED) ?: return
val entries = resources.getStringArray(R.array.backup_frequency)
val entryValues = resources.getStringArray(R.array.values_backup_frequency)
viewModel.periodicalBackupFrequency.observe(viewLifecycleOwner) { freq ->
preference.summary = if (freq == 0L) {
getString(R.string.disabled)
} else {
val index = entryValues.indexOf(freq.toString())
entries.getOrNull(index)
}
}
}
private fun clearSearchHistory() { private fun clearSearchHistory() {
MaterialAlertDialogBuilder(context ?: return) MaterialAlertDialogBuilder(context ?: return)
.setTitle(R.string.clear_search_history) .setTitle(R.string.clear_search_history)

View File

@@ -5,12 +5,15 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.update import kotlinx.coroutines.flow.update
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okhttp3.Cache import okhttp3.Cache
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
@@ -29,6 +32,7 @@ class UserDataSettingsViewModel @Inject constructor(
private val searchRepository: MangaSearchRepository, private val searchRepository: MangaSearchRepository,
private val trackingRepository: TrackingRepository, private val trackingRepository: TrackingRepository,
private val cookieJar: MutableCookieJar, private val cookieJar: MutableCookieJar,
private val settings: AppSettings,
) : BaseViewModel() { ) : BaseViewModel() {
val onActionDone = MutableEventFlow<ReversibleAction>() val onActionDone = MutableEventFlow<ReversibleAction>()
@@ -40,6 +44,20 @@ class UserDataSettingsViewModel @Inject constructor(
val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java) val cacheSizes = EnumMap<CacheDir, MutableStateFlow<Long>>(CacheDir::class.java)
val storageUsage = MutableStateFlow<StorageUsage?>(null) val storageUsage = MutableStateFlow<StorageUsage?>(null)
val periodicalBackupFrequency = settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
valueProducer = { isPeriodicalBackupEnabled },
).flatMapLatest { isEnabled ->
if (isEnabled) {
settings.observeAsFlow(
key = AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY,
valueProducer = { periodicalBackupFrequency },
)
} else {
flowOf(0)
}
}
private var storageUsageJob: Job? = null private var storageUsageJob: Job? = null
init { init {

View File

@@ -5,6 +5,7 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupWorker
import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker import org.koitharu.kotatsu.suggestions.ui.SuggestionsWorker
import org.koitharu.kotatsu.tracker.work.TrackWorker import org.koitharu.kotatsu.tracker.work.TrackWorker
import javax.inject.Inject import javax.inject.Inject
@@ -13,6 +14,7 @@ class WorkScheduleManager @Inject constructor(
private val settings: AppSettings, private val settings: AppSettings,
private val suggestionScheduler: SuggestionsWorker.Scheduler, private val suggestionScheduler: SuggestionsWorker.Scheduler,
private val trackerScheduler: TrackWorker.Scheduler, private val trackerScheduler: TrackWorker.Scheduler,
private val periodicalBackupScheduler: PeriodicalBackupWorker.Scheduler,
) : SharedPreferences.OnSharedPreferenceChangeListener { ) : SharedPreferences.OnSharedPreferenceChangeListener {
override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) {
@@ -30,6 +32,13 @@ class WorkScheduleManager @Inject constructor(
isEnabled = settings.isSuggestionsEnabled, isEnabled = settings.isSuggestionsEnabled,
force = key != AppSettings.KEY_SUGGESTIONS, force = key != AppSettings.KEY_SUGGESTIONS,
) )
AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
AppSettings.KEY_BACKUP_PERIODICAL_FREQUENCY -> updateWorker(
scheduler = periodicalBackupScheduler,
isEnabled = settings.isPeriodicalBackupEnabled,
force = key != AppSettings.KEY_BACKUP_PERIODICAL_ENABLED,
)
} }
} }
@@ -38,6 +47,7 @@ class WorkScheduleManager @Inject constructor(
processLifecycleScope.launch(Dispatchers.Default) { processLifecycleScope.launch(Dispatchers.Default) {
updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, false) updateWorkerImpl(trackerScheduler, settings.isTrackerEnabled, false)
updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false) updateWorkerImpl(suggestionScheduler, settings.isSuggestionsEnabled, false)
updateWorkerImpl(periodicalBackupScheduler, settings.isPeriodicalBackupEnabled, false)
} }
} }

View File

@@ -130,9 +130,6 @@ class SyncHelper @AssistedInject constructor(
private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> { private fun upsertHistory(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityHistory, TABLE_HISTORY) val uri = uri(authorityHistory, TABLE_HISTORY)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("updated_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo -> json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory)) operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityHistory))
ContentProviderOperation.newInsert(uri) ContentProviderOperation.newInsert(uri)
@@ -145,9 +142,6 @@ class SyncHelper @AssistedInject constructor(
private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array<ContentProviderResult> { private fun upsertFavouriteCategories(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES) val uri = uri(authorityFavourites, TABLE_FAVOURITE_CATEGORIES)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo -> json.mapJSONTo(operations) { jo ->
ContentProviderOperation.newInsert(uri) ContentProviderOperation.newInsert(uri)
.withValues(jo.toContentValues()) .withValues(jo.toContentValues())
@@ -159,9 +153,6 @@ class SyncHelper @AssistedInject constructor(
private fun upsertFavourites(json: JSONArray, timestamp: Long): Array<ContentProviderResult> { private fun upsertFavourites(json: JSONArray, timestamp: Long): Array<ContentProviderResult> {
val uri = uri(authorityFavourites, TABLE_FAVOURITES) val uri = uri(authorityFavourites, TABLE_FAVOURITES)
val operations = ArrayList<ContentProviderOperation>() val operations = ArrayList<ContentProviderOperation>()
operations += ContentProviderOperation.newDelete(uri)
.withSelection("created_at < ?", arrayOf(timestamp.toString()))
.build()
json.mapJSONTo(operations) { jo -> json.mapJSONTo(operations) { jo ->
operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites)) operations.addAll(upsertManga(jo.removeJSONObject("manga"), authorityFavourites))
ContentProviderOperation.newInsert(uri) ContentProviderOperation.newInsert(uri)

View File

@@ -104,31 +104,6 @@
app:layout_constraintTop_toBottomOf="@id/textView_status" app:layout_constraintTop_toBottomOf="@id/textView_status"
tools:text="@tools:sample/lorem[3]" /> tools:text="@tools:sample/lorem[3]" />
<com.google.android.material.card.MaterialCardView
android:id="@+id/card_details"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginHorizontal="12dp"
android:layout_marginTop="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHeight_default="wrap"
app:layout_constraintHeight_max="280dp"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/progressBar"
app:shapeAppearance="?shapeAppearanceCornerMedium">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView_chapters"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical"
android:scrollbars="vertical"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="200"
tools:listitem="@layout/item_chapter_download" />
</com.google.android.material.card.MaterialCardView>
<Button <Button
android:id="@+id/button_pause" android:id="@+id/button_pause"
style="?materialButtonOutlinedStyle" style="?materialButtonOutlinedStyle"
@@ -139,7 +114,7 @@
android:text="@string/pause" android:text="@string/pause"
android:visibility="gone" android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_resume" app:layout_constraintEnd_toStartOf="@id/button_resume"
app:layout_constraintTop_toBottomOf="@id/card_details" app:layout_constraintTop_toBottomOf="@id/progressBar"
tools:visibility="visible" /> tools:visibility="visible" />
<Button <Button
@@ -152,7 +127,7 @@
android:text="@string/resume" android:text="@string/resume"
android:visibility="gone" android:visibility="gone"
app:layout_constraintEnd_toStartOf="@id/button_cancel" app:layout_constraintEnd_toStartOf="@id/button_cancel"
app:layout_constraintTop_toBottomOf="@id/card_details" /> app:layout_constraintTop_toBottomOf="@id/progressBar" />
<Button <Button
android:id="@+id/button_cancel" android:id="@+id/button_cancel"
@@ -164,7 +139,7 @@
android:text="@android:string/cancel" android:text="@android:string/cancel"
android:visibility="gone" android:visibility="gone"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toBottomOf="@id/card_details" app:layout_constraintTop_toBottomOf="@id/progressBar"
tools:visibility="visible" /> tools:visibility="visible" />
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>

View File

@@ -2,18 +2,58 @@
<resources> <resources>
<plurals name="new_chapters"> <plurals name="new_chapters">
<item quantity="zero">%1$d فصل جديد</item> <item quantity="zero">%1$d فصل جديد</item>
<item quantity="one"/> <item quantity="one">%1$d فصل جديد</item>
<item quantity="two"/> <item quantity="two">%1$d فصول جديدة</item>
<item quantity="few"/> <item quantity="few">%1$d فصول جديدة</item>
<item quantity="many">%1$d فصول جديدة</item> <item quantity="many">%1$d فصول جديدة</item>
<item quantity="other"/> <item quantity="other">%1$d فصول جديدة</item>
</plurals> </plurals>
<plurals name="chapters"> <plurals name="chapters">
<item quantity="zero">لا يوجد</item> <item quantity="zero">%1$d فصل</item>
<item quantity="one">%1$d فصل</item> <item quantity="one">%1$d فصل</item>
<item quantity="two">%1$d فصلين</item> <item quantity="two">%1$d فصلين</item>
<item quantity="few">%1$d بعض فصول</item> <item quantity="few">%1$d فصول</item>
<item quantity="many">%1$d عدة فصول</item> <item quantity="many">%1$d فصول</item>
<item quantity="other">أخرى</item> <item quantity="other">%1$d فصول</item>
</plurals>
<plurals name="minutes_ago">
<item quantity="zero">%1$d دقيقة مضت</item>
<item quantity="one">%1$d دقيقة مضت</item>
<item quantity="two">%1$d دقائق مضت</item>
<item quantity="few">%1$d دقائق مضت</item>
<item quantity="many">%1$d دقائق مضت</item>
<item quantity="other">%1$d دقائق مضت</item>
</plurals>
<plurals name="items">
<item quantity="zero">%1$d عنصر</item>
<item quantity="one">%1$d عنصر</item>
<item quantity="two">%1$d عناصر</item>
<item quantity="few">%1$d عناصر</item>
<item quantity="many">%1$d عناصر</item>
<item quantity="other">%1$d عناصر</item>
</plurals>
<plurals name="months_ago">
<item quantity="zero">%1$d شهر مضا</item>
<item quantity="one">%1$d شهر مضا</item>
<item quantity="two">%1$d شهرين مضت</item>
<item quantity="few">%1$d أشهر مضت</item>
<item quantity="many">%1$d أشهر مضت</item>
<item quantity="other">%1$d أشهر مضت</item>
</plurals>
<plurals name="days_ago">
<item quantity="zero">%1$d يوم مضا</item>
<item quantity="one">%1$d يوم مضا</item>
<item quantity="two">%1$d يومين مضت</item>
<item quantity="few">%1$d أيام مضت</item>
<item quantity="many">%1$d أيام مضت</item>
<item quantity="other">%1$d أيام مضت</item>
</plurals>
<plurals name="hours_ago">
<item quantity="zero">%1$d ساعة مضت</item>
<item quantity="one">%1$d ساعة مضت</item>
<item quantity="two">%1$d ساعات مضت</item>
<item quantity="few">%1$d ساعات مضت</item>
<item quantity="many">%1$d ساعات مضت</item>
<item quantity="other">%1$d ساعات مضت</item>
</plurals> </plurals>
</resources> </resources>

View File

@@ -156,7 +156,7 @@
<string name="long_ago">منذ فترة</string> <string name="long_ago">منذ فترة</string>
<string name="notifications_settings">إعدادات الإشعارات</string> <string name="notifications_settings">إعدادات الإشعارات</string>
<string name="save_manga">حفظ</string> <string name="save_manga">حفظ</string>
<string name="large_manga_save_confirm">تحتوي هذه المانجا على s%. حفظ الكل؟</string> <string name="large_manga_save_confirm">هذه المانجا فيها %s . حفظ الكل</string>
<string name="read_more">اقرأ المزيد</string> <string name="read_more">اقرأ المزيد</string>
<string name="search_results">نتائج البحث</string> <string name="search_results">نتائج البحث</string>
<string name="file_not_found">الملف غير موجود</string> <string name="file_not_found">الملف غير موجود</string>
@@ -172,9 +172,10 @@
<string name="data_restored_success">استعيدت جميع البيانات</string> <string name="data_restored_success">استعيدت جميع البيانات</string>
<string name="backup_information">يمكنك إنشاء نسخة احتياطية من السجل الخاص بك والمفضلة واستعادتها</string> <string name="backup_information">يمكنك إنشاء نسخة احتياطية من السجل الخاص بك والمفضلة واستعادتها</string>
<string name="available_sources">المصادر المتاحة</string> <string name="available_sources">المصادر المتاحة</string>
<string name="external_storage">التخزين الخارجي</string> <string name="external_storage">تخزين خارجي</string>
<string name="silent">صامت</string> <string name="silent">صامت</string>
<string name="today">اليوم</string> <string name="today">اليوم</string>
<string name="system_default">الافتراضي</string> <string name="system_default">الافتراضي</string>
<string name="sign_in">تسجبل الدخول</string> <string name="sign_in">تسجبل الدخول</string>
<string name="domain">المجال</string>
</resources> </resources>

View File

@@ -494,4 +494,13 @@
<string name="enhanced_colors_summary">Reduce el banding, pero puede afectar al rendimiento</string> <string name="enhanced_colors_summary">Reduce el banding, pero puede afectar al rendimiento</string>
<string name="by_relevance">Relevancia</string> <string name="by_relevance">Relevancia</string>
<string name="online_variant">Variante en línea</string> <string name="online_variant">Variante en línea</string>
<string name="frequency_every_day">Cada día</string>
<string name="backup_frequency">Frecuencia de creación de las copias de seguridad</string>
<string name="periodic_backups_enable">Activar las copias de seguridad periódicas</string>
<string name="frequency_every_2_days">Cada 2 días</string>
<string name="frequency_once_per_week">Una vez a la semana</string>
<string name="periodic_backups">Copias de seguridad periódicas</string>
<string name="frequency_twice_per_month">Dos veces al mes</string>
<string name="frequency_once_per_month">Una vez al mes</string>
<string name="backups_output_directory">Directorio para guardar la copia de seguridad</string>
</resources> </resources>

View File

@@ -494,4 +494,13 @@
<string name="list_options">Opsyon sa Listahan</string> <string name="list_options">Opsyon sa Listahan</string>
<string name="by_relevance">Kaugnayan</string> <string name="by_relevance">Kaugnayan</string>
<string name="online_variant">Online na baryante</string> <string name="online_variant">Online na baryante</string>
<string name="frequency_every_day">Araw araw</string>
<string name="backup_frequency">Dalas ng paglikha ng backup</string>
<string name="periodic_backups_enable">Paganahin ang periodic na pag-backup</string>
<string name="frequency_every_2_days">Kada 2 araw</string>
<string name="frequency_once_per_week">Isang beses kada linggo</string>
<string name="periodic_backups">Mga periodic na pag-backup</string>
<string name="frequency_twice_per_month">Dalawang beses bawat buwan</string>
<string name="frequency_once_per_month">Isang beses bawat buwan</string>
<string name="backups_output_directory">Output directory ng mga backup</string>
</resources> </resources>

View File

@@ -41,11 +41,11 @@
<string name="remove">Remover</string> <string name="remove">Remover</string>
<string name="_s_deleted_from_local_storage">«%s» deletado do armazenamento local</string> <string name="_s_deleted_from_local_storage">«%s» deletado do armazenamento local</string>
<string name="save_page">Salvar página</string> <string name="save_page">Salvar página</string>
<string name="page_saved">Salvou</string> <string name="page_saved">Salvo</string>
<string name="share_image">Compartilhar imagem</string> <string name="share_image">Compartilhar imagem</string>
<string name="_import">Importar</string> <string name="_import">Importar</string>
<string name="updated">Atualizado</string> <string name="updated">Atualizado</string>
<string name="delete">Deletar</string> <string name="delete">Delete</string>
<string name="operation_not_supported">Essa operação não é suportada</string> <string name="operation_not_supported">Essa operação não é suportada</string>
<string name="clear_pages_cache">Limpar cache de página</string> <string name="clear_pages_cache">Limpar cache de página</string>
<string name="text_file_sizes">B|kB|MB|GB|TB</string> <string name="text_file_sizes">B|kB|MB|GB|TB</string>
@@ -66,10 +66,10 @@
<string name="internal_storage">Armazenamento interno</string> <string name="internal_storage">Armazenamento interno</string>
<string name="external_storage">Armazenamento externo</string> <string name="external_storage">Armazenamento externo</string>
<string name="domain">Domínio</string> <string name="domain">Domínio</string>
<string name="app_update_available">Uma nova versão da app está disponível</string> <string name="app_update_available">Uma nova versão do app está disponível</string>
<string name="open_in_browser">Abrir no navegador da web</string> <string name="open_in_browser">Abrir no navegador da web</string>
<string name="large_manga_save_confirm">Este mangá tem %s. Salvar tudo isso\?</string> <string name="large_manga_save_confirm">Este mangá tem %s. Salvar tudo isso\?</string>
<string name="save_manga">Salve</string> <string name="save_manga">Salvar</string>
<string name="notifications">Notificações</string> <string name="notifications">Notificações</string>
<string name="new_chapters">Novos capítulos</string> <string name="new_chapters">Novos capítulos</string>
<string name="download">Download</string> <string name="download">Download</string>
@@ -483,4 +483,15 @@
<string name="show">Mostrar</string> <string name="show">Mostrar</string>
<string name="color_black">Preto</string> <string name="color_black">Preto</string>
<string name="this_month">Este mês</string> <string name="this_month">Este mês</string>
<string name="categories">Categorias</string>
<string name="list_options">Listar opções</string>
<string name="suggest_new_sources">Sugira novas fontes após atualização do app</string>
<string name="enhanced_colors_summary">Reduz faixas, mas pode afetar o desempenho</string>
<string name="online_variant">Variante online</string>
<string name="by_relevance">Relevância</string>
<string name="enhanced_colors">Modo de cor 32-bit</string>
<string name="suggest_new_sources_summary">Solicitar a ativação de fontes recém-adicionadas após atualizar o aplicativo</string>
<string name="state_abandoned">Caiu</string>
<string name="keep_screen_on">Manter a tela ligada</string>
<string name="keep_screen_on_summary">Não desligue a tela enquanto estiver lendo mangá</string>
</resources> </resources>

View File

@@ -494,4 +494,14 @@
<string name="enhanced_colors">32-битный цветовой режим</string> <string name="enhanced_colors">32-битный цветовой режим</string>
<string name="suggest_new_sources_summary">Предлагать источники манги, добавленные в последнем обновлении приложения</string> <string name="suggest_new_sources_summary">Предлагать источники манги, добавленные в последнем обновлении приложения</string>
<string name="online_variant">Онлайн вариант</string> <string name="online_variant">Онлайн вариант</string>
<string name="frequency_every_day">Каждый день</string>
<string name="backup_frequency">Частота создания резервных копий</string>
<string name="periodic_backups_enable">Включить резервное копирование по расписанию</string>
<string name="frequency_every_2_days">Каждые 2 дня</string>
<string name="frequency_once_per_week">Раз в неделю</string>
<string name="periodic_backups">Резервное копирование по расписанию</string>
<string name="frequency_twice_per_month">Дважды в месяц</string>
<string name="frequency_once_per_month">Один раз в месяц</string>
<string name="backups_output_directory">Каталог для сохранения резервных копий</string>
<string name="last_successful_backup">Последняя резервная копия: %s</string>
</resources> </resources>

View File

@@ -494,4 +494,13 @@
<string name="list_options">Seçenekleri listele</string> <string name="list_options">Seçenekleri listele</string>
<string name="online_variant">Çevrimiçi varyant</string> <string name="online_variant">Çevrimiçi varyant</string>
<string name="by_relevance">Alaka düzeyi</string> <string name="by_relevance">Alaka düzeyi</string>
<string name="frequency_every_day">Her gün</string>
<string name="backup_frequency">Yedek oluşturma sıklığı</string>
<string name="periodic_backups_enable">Zamanlı yedeklemeleri etkinleştirin</string>
<string name="frequency_every_2_days">2 günde 1</string>
<string name="frequency_once_per_week">Haftada 1 kez</string>
<string name="periodic_backups">Zamanlı yedekleme</string>
<string name="frequency_twice_per_month">Ayda 2 kere</string>
<string name="frequency_once_per_month">Ayda 1 kere</string>
<string name="backups_output_directory">Yedekleme dizini</string>
</resources> </resources>

View File

@@ -1,51 +1,51 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<string-array name="themes"> <string-array name="themes" translatable="false">
<item>@string/automatic</item> <item>@string/automatic</item>
<item>@string/light</item> <item>@string/light</item>
<item>@string/dark</item> <item>@string/dark</item>
</string-array> </string-array>
<string-array name="reader_switchers"> <string-array name="reader_switchers" translatable="false">
<item>@string/taps_on_edges</item> <item>@string/taps_on_edges</item>
<item>@string/volume_buttons</item> <item>@string/volume_buttons</item>
</string-array> </string-array>
<string-array name="zoom_modes"> <string-array name="zoom_modes" translatable="false">
<item>@string/zoom_mode_fit_center</item> <item>@string/zoom_mode_fit_center</item>
<item>@string/zoom_mode_fit_height</item> <item>@string/zoom_mode_fit_height</item>
<item>@string/zoom_mode_fit_width</item> <item>@string/zoom_mode_fit_width</item>
<item>@string/zoom_mode_keep_start</item> <item>@string/zoom_mode_keep_start</item>
</string-array> </string-array>
<string-array name="track_sources"> <string-array name="track_sources" translatable="false">
<item>@string/favourites</item> <item>@string/favourites</item>
<item>@string/history</item> <item>@string/history</item>
</string-array> </string-array>
<string-array name="list_modes"> <string-array name="list_modes" translatable="false">
<item>@string/list</item> <item>@string/list</item>
<item>@string/detailed_list</item> <item>@string/detailed_list</item>
<item>@string/grid</item> <item>@string/grid</item>
</string-array> </string-array>
<string-array name="screenshots_policy"> <string-array name="screenshots_policy" translatable="false">
<item>@string/screenshots_allow</item> <item>@string/screenshots_allow</item>
<item>@string/screenshots_block_nsfw</item> <item>@string/screenshots_block_nsfw</item>
<item>@string/screenshots_block_all</item> <item>@string/screenshots_block_all</item>
</string-array> </string-array>
<string-array name="network_policy"> <string-array name="network_policy" translatable="false">
<item>@string/always</item> <item>@string/always</item>
<item>@string/only_using_wifi</item> <item>@string/only_using_wifi</item>
<item>@string/never</item> <item>@string/never</item>
</string-array> </string-array>
<string-array name="doh_providers"> <string-array name="doh_providers" translatable="false">
<item>@string/disabled</item> <item>@string/disabled</item>
<item>Google</item> <item>Google</item>
<item>CloudFlare</item> <item>CloudFlare</item>
<item>AdGuard</item> <item>AdGuard</item>
</string-array> </string-array>
<string-array name="reader_modes"> <string-array name="reader_modes" translatable="false">
<item>@string/standard</item> <item>@string/standard</item>
<item>@string/right_to_left</item> <item>@string/right_to_left</item>
<item>@string/webtoon</item> <item>@string/webtoon</item>
</string-array> </string-array>
<string-array name="scrobbling_statuses"> <string-array name="scrobbling_statuses" translatable="false">
<item>@string/status_planned</item> <item>@string/status_planned</item>
<item>@string/status_reading</item> <item>@string/status_reading</item>
<item>@string/status_re_reading</item> <item>@string/status_re_reading</item>
@@ -53,25 +53,32 @@
<item>@string/status_on_hold</item> <item>@string/status_on_hold</item>
<item>@string/status_dropped</item> <item>@string/status_dropped</item>
</string-array> </string-array>
<string-array name="proxy_types"> <string-array name="proxy_types" translatable="false">
<item>@string/disabled</item> <item>@string/disabled</item>
<item>HTTP</item> <item>HTTP</item>
<item>SOCKS (v4/v5)</item> <item>SOCKS (v4/v5)</item>
</string-array> </string-array>
<string-array name="reader_backgrounds"> <string-array name="reader_backgrounds" translatable="false">
<item>@string/system_default</item> <item>@string/system_default</item>
<item>@string/color_light</item> <item>@string/color_light</item>
<item>@string/color_dark</item> <item>@string/color_dark</item>
<item>@string/color_white</item> <item>@string/color_white</item>
<item>@string/color_black</item> <item>@string/color_black</item>
</string-array> </string-array>
<string-array name="reader_animation"> <string-array name="reader_animation" translatable="false">
<item>@string/disabled</item> <item>@string/disabled</item>
<item>@string/system_default</item> <item>@string/system_default</item>
<item>@string/advanced</item> <item>@string/advanced</item>
</string-array> </string-array>
<string-array name="first_nav_item"> <string-array name="first_nav_item" translatable="false">
<item>@string/history</item> <item>@string/history</item>
<item>@string/favourites</item> <item>@string/favourites</item>
</string-array> </string-array>
<string-array name="backup_frequency" translatable="false">
<item>@string/frequency_every_day</item>
<item>@string/frequency_every_2_days</item>
<item>@string/frequency_once_per_week</item>
<item>@string/frequency_twice_per_month</item>
<item>@string/frequency_once_per_month</item>
</string-array>
</resources> </resources>

View File

@@ -64,4 +64,11 @@
<item>0</item> <item>0</item>
<item>1</item> <item>1</item>
</string-array> </string-array>
<string-array name="values_backup_frequency" translatable="false">
<item>1</item>
<item>2</item>
<item>7</item>
<item>14</item>
<item>30</item>
</string-array>
</resources> </resources>

View File

@@ -500,4 +500,14 @@
<string name="by_relevance">Relevance</string> <string name="by_relevance">Relevance</string>
<string name="categories">Categories</string> <string name="categories">Categories</string>
<string name="online_variant">Online variant</string> <string name="online_variant">Online variant</string>
<string name="periodic_backups">Periodic backups</string>
<string name="backup_frequency">Backup creation frequency</string>
<string name="frequency_every_day">Every day</string>
<string name="frequency_every_2_days">Every 2 days</string>
<string name="frequency_once_per_week">Once per week</string>
<string name="frequency_twice_per_month">Twice per month</string>
<string name="frequency_once_per_month">Once per month</string>
<string name="periodic_backups_enable">Enable periodic backups</string>
<string name="backups_output_directory">Backups output directory</string>
<string name="last_successful_backup">Last successful backup: %s</string>
</resources> </resources>

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.preference.PreferenceScreen
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<SwitchPreferenceCompat
android:defaultValue="false"
android:key="backup_periodic"
android:layout="@layout/preference_toggle_header"
android:title="@string/periodic_backups_enable" />
<ListPreference
android:defaultValue="7"
android:dependency="backup_periodic"
android:entries="@array/backup_frequency"
android:entryValues="@array/values_backup_frequency"
android:key="backup_periodic_freq"
android:title="@string/backup_frequency"
app:useSimpleSummaryProvider="true" />
<Preference
android:dependency="backup_periodic"
android:key="backup_periodic_output"
android:title="@string/backups_output_directory" />
<Preference
android:dependency="backup_periodic"
android:icon="@drawable/ic_info_outline"
android:key="backup_periodic_last"
android:persistent="false"
android:selectable="false"
app:allowDividerAbove="true"
app:isPreferenceVisible="false" />
</androidx.preference.PreferenceScreen>

View File

@@ -34,6 +34,12 @@
android:summary="@string/restore_summary" android:summary="@string/restore_summary"
android:title="@string/restore_backup" /> android:title="@string/restore_backup" />
<Preference
android:fragment="org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment"
android:key="backup_periodic"
android:persistent="false"
android:title="@string/periodic_backups" />
</PreferenceCategory> </PreferenceCategory>
<PreferenceCategory android:title="@string/storage_usage"> <PreferenceCategory android:title="@string/storage_usage">