Compare commits

..

8 Commits

Author SHA1 Message Date
4e0e5be726 Update parsers
(cherry picked from commit 451a155e08)
2023-12-16 08:13:24 +02:00
Weblate (bot)
f18eca52af Translations update from Hosted Weblate (#594)
* Translated using Weblate (Thai)

Currently translated at 71.1% (387 of 544 strings)

Co-authored-by: Nayuki <me@nayuki.cyou>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/th/
Translation: Kotatsu/Strings

* Translated using Weblate (Spanish)

Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/es/
Translation: Kotatsu/Strings

* Translated using Weblate (Turkish)

Currently translated at 100.0% (546 of 546 strings)

Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/tr/
Translation: Kotatsu/Strings

* Translated using Weblate (Russian)

Currently translated at 99.8% (545 of 546 strings)

Co-authored-by: Hotarun <ihotarun@proton.me>
Translate-URL: https://hosted.weblate.org/projects/kotatsu/strings/ru/
Translation: Kotatsu/Strings

---------

Co-authored-by: Nayuki <me@nayuki.cyou>
Co-authored-by: gallegonovato <fran-carro@hotmail.es>
Co-authored-by: Oğuz Ersen <oguz@ersen.moe>
Co-authored-by: Hotarun <ihotarun@proton.me>
(cherry picked from commit 0612a7ad2c)
2023-12-16 08:13:17 +02:00
808fd13ad0 Fix saved chapters mapping 2023-12-14 15:16:15 +02:00
88c8dc4761 Fix crashes 2023-12-14 14:50:56 +02:00
a7eba67a97 Webtoon reader fixes 2023-12-14 14:36:03 +02:00
c27586231a Select which data will be restored from backup 2023-12-13 14:37:05 +02:00
db3db4637c Support manga sources in backups 2023-12-13 12:16:06 +02:00
bb2294f248 UI improvements 2023-12-13 11:33:55 +02:00
40 changed files with 884 additions and 334 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 = 604 versionCode = 606
versionName = '6.5-b2' versionName = '6.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:97338f31c5') { implementation('com.github.KotatsuApp:kotatsu-parsers:42ecf8a958') {
exclude group: 'org.json', module: 'json' exclude group: 'org.json', module: 'json'
} }

View File

@@ -3,17 +3,20 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONArray import org.json.JSONArray
class BackupEntry( class BackupEntry(
val name: String, val name: Name,
val data: JSONArray val data: JSONArray
) { ) {
companion object Names { enum class Name(
val key: String,
) {
const val INDEX = "index" INDEX("index"),
const val HISTORY = "history" HISTORY("history"),
const val CATEGORIES = "categories" CATEGORIES("categories"),
const val FAVOURITES = "favourites" FAVOURITES("favourites"),
const val SETTINGS = "settings" SETTINGS("settings"),
const val BOOKMARKS = "bookmarks" BOOKMARKS("bookmarks"),
SOURCES("sources"),
} }
} }

View File

@@ -7,8 +7,10 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.MangaDatabase
import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.util.json.JSONIterator import org.koitharu.kotatsu.parsers.util.json.JSONIterator
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.json.mapJSON
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.Date
import javax.inject.Inject import javax.inject.Inject
private const val PAGE_SIZE = 10 private const val PAGE_SIZE = 10
@@ -20,7 +22,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpHistory(): BackupEntry { suspend fun dumpHistory(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.HISTORY, JSONArray()) val entry = BackupEntry(BackupEntry.Name.HISTORY, JSONArray())
while (true) { while (true) {
val history = db.getHistoryDao().findAll(offset, PAGE_SIZE) val history = db.getHistoryDao().findAll(offset, PAGE_SIZE)
if (history.isEmpty()) { if (history.isEmpty()) {
@@ -41,7 +43,7 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpCategories(): BackupEntry { suspend fun dumpCategories(): BackupEntry {
val entry = BackupEntry(BackupEntry.CATEGORIES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.CATEGORIES, JSONArray())
val categories = db.getFavouriteCategoriesDao().findAll() val categories = db.getFavouriteCategoriesDao().findAll()
for (item in categories) { for (item in categories) {
entry.data.put(JsonSerializer(item).toJson()) entry.data.put(JsonSerializer(item).toJson())
@@ -51,7 +53,7 @@ class BackupRepository @Inject constructor(
suspend fun dumpFavourites(): BackupEntry { suspend fun dumpFavourites(): BackupEntry {
var offset = 0 var offset = 0
val entry = BackupEntry(BackupEntry.FAVOURITES, JSONArray()) val entry = BackupEntry(BackupEntry.Name.FAVOURITES, JSONArray())
while (true) { while (true) {
val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE) val favourites = db.getFavouritesDao().findAll(offset, PAGE_SIZE)
if (favourites.isEmpty()) { if (favourites.isEmpty()) {
@@ -72,7 +74,7 @@ class BackupRepository @Inject constructor(
} }
suspend fun dumpBookmarks(): BackupEntry { suspend fun dumpBookmarks(): BackupEntry {
val entry = BackupEntry(BackupEntry.BOOKMARKS, JSONArray()) val entry = BackupEntry(BackupEntry.Name.BOOKMARKS, JSONArray())
val all = db.getBookmarksDao().findAll() val all = db.getBookmarksDao().findAll()
for ((m, b) in all) { for ((m, b) in all) {
val json = JSONObject() val json = JSONObject()
@@ -90,7 +92,7 @@ class BackupRepository @Inject constructor(
} }
fun dumpSettings(): BackupEntry { fun dumpSettings(): BackupEntry {
val entry = BackupEntry(BackupEntry.SETTINGS, JSONArray()) val entry = BackupEntry(BackupEntry.Name.SETTINGS, JSONArray())
val settingsDump = settings.getAllValues().toMutableMap() val settingsDump = settings.getAllValues().toMutableMap()
settingsDump.remove(AppSettings.KEY_APP_PASSWORD) settingsDump.remove(AppSettings.KEY_APP_PASSWORD)
settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD) settingsDump.remove(AppSettings.KEY_PROXY_PASSWORD)
@@ -101,8 +103,18 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
suspend fun dumpSources(): BackupEntry {
val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray())
val all = db.getSourcesDao().findAll()
for (source in all) {
val json = JsonSerializer(source).toJson()
entry.data.put(json)
}
return entry
}
fun createIndex(): BackupEntry { fun createIndex(): BackupEntry {
val entry = BackupEntry(BackupEntry.INDEX, JSONArray()) val entry = BackupEntry(BackupEntry.Name.INDEX, JSONArray())
val json = JSONObject() val json = JSONObject()
json.put("app_id", BuildConfig.APPLICATION_ID) json.put("app_id", BuildConfig.APPLICATION_ID)
json.put("app_version", BuildConfig.VERSION_CODE) json.put("app_version", BuildConfig.VERSION_CODE)
@@ -111,6 +123,11 @@ class BackupRepository @Inject constructor(
return entry return entry
} }
fun getBackupDate(entry: BackupEntry?): Date? {
val timestamp = entry?.data?.optJSONObject(0)?.getLongOrDefault("created_at", 0) ?: 0
return if (timestamp == 0L) null else Date(timestamp)
}
suspend fun restoreHistory(entry: BackupEntry): CompositeResult { suspend fun restoreHistory(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {
@@ -184,6 +201,17 @@ class BackupRepository @Inject constructor(
return result return result
} }
suspend fun restoreSources(entry: BackupEntry): CompositeResult {
val result = CompositeResult()
for (item in entry.data.JSONIterator()) {
val source = JsonDeserializer(item).toMangaSourceEntity()
result += runCatchingCancellable {
db.getSourcesDao().upsert(source)
}
}
return result
}
fun restoreSettings(entry: BackupEntry): CompositeResult { fun restoreSettings(entry: BackupEntry): CompositeResult {
val result = CompositeResult() val result = CompositeResult()
for (item in entry.data.JSONIterator()) { for (item in entry.data.JSONIterator()) {

View File

@@ -1,25 +1,44 @@
package org.koitharu.kotatsu.core.backup package org.koitharu.kotatsu.core.backup
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.runInterruptible
import okio.Closeable import okio.Closeable
import org.json.JSONArray import org.json.JSONArray
import org.koitharu.kotatsu.core.util.ext.processLifecycleScope
import java.io.File import java.io.File
import java.util.EnumSet
import java.util.zip.ZipFile import java.util.zip.ZipFile
class BackupZipInput(val file: File) : Closeable { class BackupZipInput(val file: File) : Closeable {
private val zipFile = ZipFile(file) private val zipFile = ZipFile(file)
suspend fun getEntry(name: String): BackupEntry? = runInterruptible(Dispatchers.IO) { suspend fun getEntry(name: BackupEntry.Name): BackupEntry? = runInterruptible(Dispatchers.IO) {
val entry = zipFile.getEntry(name) ?: return@runInterruptible null val entry = zipFile.getEntry(name.key) ?: return@runInterruptible null
val json = zipFile.getInputStream(entry).use { val json = zipFile.getInputStream(entry).use {
JSONArray(it.bufferedReader().readText()) JSONArray(it.bufferedReader().readText())
} }
BackupEntry(name, json) BackupEntry(name, json)
} }
suspend fun entries(): Set<BackupEntry.Name> = runInterruptible(Dispatchers.IO) {
zipFile.entries().toList().mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { ze ->
BackupEntry.Name.entries.find { it.key == ze.name }
}
}
override fun close() { override fun close() {
zipFile.close() zipFile.close()
} }
fun cleanupAsync() {
processLifecycleScope.launch(Dispatchers.IO, CoroutineStart.ATOMIC) {
runCatching {
close()
file.delete()
}
}
}
} }

View File

@@ -17,7 +17,7 @@ class BackupZipOutput(val file: File) : Closeable {
private val output = ZipOutput(file, Deflater.BEST_COMPRESSION) private val output = ZipOutput(file, Deflater.BEST_COMPRESSION)
suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) { suspend fun put(entry: BackupEntry) = runInterruptible(Dispatchers.IO) {
output.put(entry.name, entry.data.toString(2)) output.put(entry.name.key, entry.data.toString(2))
} }
suspend fun finish() = runInterruptible(Dispatchers.IO) { suspend fun finish() = runInterruptible(Dispatchers.IO) {

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -78,6 +79,12 @@ class JsonDeserializer(private val json: JSONObject) {
percent = json.getDouble("percent").toFloat(), percent = json.getDouble("percent").toFloat(),
) )
fun toMangaSourceEntity() = MangaSourceEntity(
source = json.getString("source"),
isEnabled = json.getBoolean("enabled"),
sortKey = json.getInt("sort_key"),
)
fun toMap(): Map<String, Any?> { fun toMap(): Map<String, Any?> {
val map = mutableMapOf<String, Any?>() val map = mutableMapOf<String, Any?>()
val keys = json.keys() val keys = json.keys()

View File

@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.backup
import org.json.JSONObject import org.json.JSONObject
import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity
import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity
import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity
import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.core.db.entity.TagEntity
import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity
import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity
@@ -82,6 +83,14 @@ class JsonSerializer private constructor(private val json: JSONObject) {
}, },
) )
constructor(e: MangaSourceEntity) : this(
JSONObject().apply {
put("source", e.source)
put("enabled", e.isEnabled)
put("sort_key", e.sortKey)
},
)
constructor(m: Map<String, *>) : this( constructor(m: Map<String, *>) : this(
JSONObject(m), JSONObject(m),
) )

View File

@@ -91,7 +91,7 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_DETAILS intent.action = ACTION_PREFETCH_DETAILS
intent.putExtra(EXTRA_MANGA, ParcelableManga(manga)) intent.putExtra(EXTRA_MANGA, ParcelableManga(manga))
context.startService(intent) tryStart(context, intent)
} }
fun prefetchPages(context: Context, chapter: MangaChapter) { fun prefetchPages(context: Context, chapter: MangaChapter) {
@@ -99,19 +99,14 @@ class MangaPrefetchService : CoroutineIntentService() {
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_PAGES intent.action = ACTION_PREFETCH_PAGES
intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter)) intent.putExtra(EXTRA_CHAPTER, ParcelableChapter(chapter))
try { tryStart(context, intent)
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
} }
fun prefetchLast(context: Context) { fun prefetchLast(context: Context) {
if (!isPrefetchAvailable(context, null)) return if (!isPrefetchAvailable(context, null)) return
val intent = Intent(context, MangaPrefetchService::class.java) val intent = Intent(context, MangaPrefetchService::class.java)
intent.action = ACTION_PREFETCH_LAST intent.action = ACTION_PREFETCH_LAST
context.startService(intent) tryStart(context, intent)
} }
private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean { private fun isPrefetchAvailable(context: Context, source: MangaSource?): Boolean {
@@ -127,5 +122,14 @@ class MangaPrefetchService : CoroutineIntentService() {
) )
return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled return entryPoint.contentCache.isCachingEnabled && entryPoint.settings.isContentPrefetchEnabled
} }
private fun tryStart(context: Context, intent: Intent) {
try {
context.startService(intent)
} catch (e: IllegalStateException) {
// probably app is in background
e.printStackTraceDebug()
}
}
} }
} }

View File

@@ -65,10 +65,7 @@ fun categoryAD(
binding.imageViewEdit.setOnClickListener(eventListener) binding.imageViewEdit.setOnClickListener(eventListener)
binding.imageViewHandle.setOnTouchListener(eventListener) binding.imageViewHandle.setOnTouchListener(eventListener)
bind { payloads -> bind {
if (payloads.isNotEmpty()) {
return@bind
}
binding.textViewTitle.text = item.category.title binding.textViewTitle.text = item.category.title
binding.textViewSubtitle.text = if (item.mangaCount == 0) { binding.textViewSubtitle.text = if (item.mangaCount == 0) {
getString(R.string.empty) getString(R.string.empty)

View File

@@ -15,13 +15,13 @@ fun categoriesHeaderAD() = adapterDelegateViewBinding<CategoriesHeaderItem, List
val onClickListener = View.OnClickListener { v -> val onClickListener = View.OnClickListener { v ->
val intent = when (v.id) { val intent = when (v.id) {
R.id.button_create -> FavouritesCategoryEditActivity.newIntent(v.context) R.id.chip_create -> FavouritesCategoryEditActivity.newIntent(v.context)
R.id.button_manage -> FavouriteCategoriesActivity.newIntent(v.context) R.id.chip_manage -> FavouriteCategoriesActivity.newIntent(v.context)
else -> return@OnClickListener else -> return@OnClickListener
} }
v.context.startActivity(intent) v.context.startActivity(intent)
} }
binding.buttonCreate.setOnClickListener(onClickListener) binding.chipCreate.setOnClickListener(onClickListener)
binding.buttonManage.setOnClickListener(onClickListener) binding.chipManage.setOnClickListener(onClickListener)
} }

View File

@@ -8,6 +8,7 @@ import android.view.ViewGroup
import android.widget.AdapterView import android.widget.AdapterView
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import androidx.core.view.isGone import androidx.core.view.isGone
import androidx.core.view.updatePadding
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import com.google.android.material.chip.Chip import com.google.android.material.chip.Chip
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
@@ -39,8 +40,11 @@ class FilterSheetFragment :
override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: SheetFilterBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
if (dialog == null && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { if (dialog == null) {
binding.scrollView.scrollIndicators = 0 binding.layoutBody.updatePadding(top = binding.layoutBody.paddingBottom)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
binding.scrollView.scrollIndicators = 0
}
} }
val filter = requireFilter() val filter = requireFilter()
filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged) filter.filterSortOrder.observe(viewLifecycleOwner, this::onSortOrderChanged)

View File

@@ -31,6 +31,7 @@ import org.koitharu.kotatsu.filter.ui.FilterOwner
import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.MangaListActivity
import org.koitharu.kotatsu.search.ui.SearchActivity import org.koitharu.kotatsu.search.ui.SearchActivity
import javax.inject.Inject import javax.inject.Inject
@@ -56,8 +57,10 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
binding.textViewAuthor.setOnClickListener(this) binding.textViewAuthor.setOnClickListener(this)
binding.imageViewCover.setOnClickListener(this) binding.imageViewCover.setOnClickListener(this)
binding.buttonOpen.setOnClickListener(this) binding.buttonOpen.setOnClickListener(this)
binding.buttonRead.setOnClickListener(this)
viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated) viewModel.manga.observe(viewLifecycleOwner, ::onMangaUpdated)
viewModel.footer.observe(viewLifecycleOwner, ::onFooterUpdated)
viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged) viewModel.tagsChips.observe(viewLifecycleOwner, ::onTagsChipsChanged)
viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged)
} }
@@ -70,6 +73,14 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
DetailsActivity.newIntent(v.context, manga), DetailsActivity.newIntent(v.context, manga),
) )
R.id.button_read -> {
startActivity(
ReaderActivity.IntentBuilder(v.context)
.manga(manga)
.build(),
)
}
R.id.textView_author -> startActivity( R.id.textView_author -> startActivity(
SearchActivity.newIntent( SearchActivity.newIntent(
context = v.context, context = v.context,
@@ -118,6 +129,43 @@ class PreviewFragment : BaseFragment<FragmentPreviewBinding>(), View.OnClickList
} }
} }
private fun onFooterUpdated(footer: PreviewViewModel.FooterInfo?) {
with(requireViewBinding()) {
toolbarBottom.isVisible = footer != null
if (footer == null) {
return
}
toolbarBottom.title = when {
footer.isInProgress() -> {
getString(R.string.chapter_d_of_d, footer.currentChapter, footer.totalChapters)
}
footer.totalChapters > 0 -> {
resources.getQuantityString(R.plurals.chapters, footer.totalChapters, footer.totalChapters)
}
else -> {
getString(R.string.no_chapters)
}
}
buttonRead.isEnabled = footer.totalChapters > 0
buttonRead.setIconResource(
when {
footer.isIncognito -> R.drawable.ic_incognito
footer.isInProgress() -> R.drawable.ic_play
else -> R.drawable.ic_read
},
)
buttonRead.setText(
if (footer.isInProgress()) {
R.string._continue
} else {
R.string.read
},
)
}
}
private fun onDescriptionChanged(description: CharSequence?) { private fun onDescriptionChanged(description: CharSequence?) {
val tv = viewBinding?.textViewDescription ?: return val tv = viewBinding?.textViewDescription ?: return
when { when {

View File

@@ -13,11 +13,14 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.distinctUntilChangedBy
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.plus import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository
@@ -25,6 +28,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.sanitize
import org.koitharu.kotatsu.history.data.HistoryRepository
import org.koitharu.kotatsu.list.domain.ListExtraProvider import org.koitharu.kotatsu.list.domain.ListExtraProvider
import javax.inject.Inject import javax.inject.Inject
@@ -33,6 +37,7 @@ class PreviewViewModel @Inject constructor(
savedStateHandle: SavedStateHandle, savedStateHandle: SavedStateHandle,
private val extraProvider: ListExtraProvider, private val extraProvider: ListExtraProvider,
private val repositoryFactory: MangaRepository.Factory, private val repositoryFactory: MangaRepository.Factory,
private val historyRepository: HistoryRepository,
private val imageGetter: Html.ImageGetter, private val imageGetter: Html.ImageGetter,
) : BaseViewModel() { ) : BaseViewModel() {
@@ -40,6 +45,26 @@ class PreviewViewModel @Inject constructor(
savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga, savedStateHandle.require<ParcelableManga>(MangaIntent.KEY_MANGA).manga,
) )
val footer = combine(
manga,
historyRepository.observeOne(manga.value.id),
manga.flatMapLatest { historyRepository.observeShouldSkip(it) }.distinctUntilChanged(),
) { m, history, incognito ->
if (m.chapters == null) {
return@combine null
}
val b = m.getPreferredBranch(history)
val chapters = m.getChapters(b).orEmpty()
FooterInfo(
branch = b,
currentChapter = history?.chapterId?.let {
chapters.indexOfFirst { x -> x.id == it }
} ?: -1,
totalChapters = chapters.size,
isIncognito = incognito,
)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null)
val description = manga val description = manga
.distinctUntilChangedBy { it.description.orEmpty() } .distinctUntilChangedBy { it.description.orEmpty() }
.transformLatest { .transformLatest {
@@ -82,4 +107,14 @@ class PreviewViewModel @Inject constructor(
} }
return spannable.trim() return spannable.trim()
} }
data class FooterInfo(
val branch: String?,
val currentChapter: Int,
val totalChapters: Int,
val isIncognito: Boolean,
) {
fun isInProgress() = currentChapter >= 0
}
} }

View File

@@ -133,7 +133,7 @@ class LocalMangaRepository @Inject constructor(
}.getOrNull() }.getOrNull()
} }
suspend fun findSavedManga(remoteManga: Manga): LocalManga? { suspend fun findSavedManga(remoteManga: Manga): LocalManga? = runCatchingCancellable {
// fast path // fast path
LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let { LocalMangaInput.find(storageManager.getReadableDirs(), remoteManga)?.let {
return it.getManga() return it.getManga()
@@ -155,7 +155,9 @@ class LocalMangaRepository @Inject constructor(
} }
} }
}.firstOrNull()?.getManga() }.firstOrNull()?.getManga()
} }.onFailure {
it.printStackTraceDebug()
}.getOrNull()
override suspend fun getPageUrl(page: MangaPage) = page.url override suspend fun getPageUrl(page: MangaPage) = page.url

View File

@@ -20,6 +20,7 @@ import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.util.toCamelCase import org.koitharu.kotatsu.parsers.util.toCamelCase
import java.io.File import java.io.File
import java.util.TreeMap
import java.util.zip.ZipFile import java.util.zip.ZipFile
/** /**
@@ -49,8 +50,15 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
url = mangaUri, url = mangaUri,
coverUrl = cover, coverUrl = cover,
largeCoverUrl = cover, largeCoverUrl = cover,
chapters = info.chapters?.mapIndexed { i, c -> chapters = info.chapters?.mapIndexedNotNull { i, c ->
c.copy(url = chapterFiles[i].toUri().toString(), source = MangaSource.LOCAL) val fileName = index.getChapterFileName(c.id)
val file = if (fileName != null) {
chapterFiles[fileName]
} else {
// old downloads
chapterFiles.values.elementAtOrNull(i)
} ?: return@mapIndexedNotNull null
c.copy(url = file.toUri().toString(), source = MangaSource.LOCAL)
}, },
) ?: Manga( ) ?: Manga(
id = root.absolutePath.longHashCode(), id = root.absolutePath.longHashCode(),
@@ -59,7 +67,7 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
publicUrl = mangaUri, publicUrl = mangaUri,
source = MangaSource.LOCAL, source = MangaSource.LOCAL,
coverUrl = findFirstImageEntry().orEmpty(), coverUrl = findFirstImageEntry().orEmpty(),
chapters = chapterFiles.mapIndexed { i, f -> chapters = chapterFiles.values.mapIndexed { i, f ->
MangaChapter( MangaChapter(
id = "$i${f.name}".longHashCode(), id = "$i${f.name}".longHashCode(),
name = f.nameWithoutExtension.toHumanReadable(), name = f.nameWithoutExtension.toHumanReadable(),
@@ -120,9 +128,9 @@ class LocalMangaDirInput(root: File) : LocalMangaInput(root) {
private fun String.toHumanReadable() = replace("_", " ").toCamelCase() private fun String.toHumanReadable() = replace("_", " ").toCamelCase()
private fun getChaptersFiles(): List<File> = root.walkCompat() private fun getChaptersFiles() = root.walkCompat()
.filter { it.hasCbzExtension() } .filter { it.hasCbzExtension() }
.toListSorted(compareBy(AlphanumComparator()) { it.name }) .associateByTo(TreeMap(AlphanumComparator())) { it.name }
private fun findFirstImageEntry(): String? { private fun findFirstImageEntry(): String? {
return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString() return root.walkCompat().firstOrNull { hasImageExtension(it) }?.toUri()?.toString()

View File

@@ -123,5 +123,11 @@ class WelcomeSheet : BaseAdaptiveSheet<SheetWelcomeBinding>(), ChipsView.OnChipC
private const val TAG = "WelcomeSheet" private const val TAG = "WelcomeSheet"
fun show(fm: FragmentManager) = WelcomeSheet().showDistinct(fm, TAG) fun show(fm: FragmentManager) = WelcomeSheet().showDistinct(fm, TAG)
fun dismiss(fm: FragmentManager): Boolean {
val sheet = fm.findFragmentByTag(TAG) as? WelcomeSheet ?: return false
sheet.dismissAllowingStateLoss()
return true
}
} }
} }

View File

@@ -12,9 +12,11 @@ class WebtoonFrameLayout @JvmOverloads constructor(
@AttrRes defStyleAttr: Int = 0, @AttrRes defStyleAttr: Int = 0,
) : FrameLayout(context, attrs, defStyleAttr) { ) : FrameLayout(context, attrs, defStyleAttr) {
val target: WebtoonImageView by lazy(LazyThreadSafetyMode.NONE) { private var _target: WebtoonImageView? = null
findViewById(R.id.ssiv) val target: WebtoonImageView
} get() = _target ?: findViewById<WebtoonImageView?>(R.id.ssiv).also {
_target = it
}
fun dispatchVerticalScroll(dy: Int): Int { fun dispatchVerticalScroll(dy: Int): Int {
if (dy == 0) { if (dy == 0) {

View File

@@ -1,14 +1,16 @@
package org.koitharu.kotatsu.reader.ui.pager.webtoon package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.graphics.PointF import android.graphics.PointF
import android.util.AttributeSet import android.util.AttributeSet
import androidx.core.view.ancestors import androidx.core.view.ancestors
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView import com.davemorrissey.labs.subscaleview.SubsamplingScaleImageView
import org.koitharu.kotatsu.parsers.util.toIntUp import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.resolveDp
private const val SCROLL_UNKNOWN = -1 import kotlin.math.roundToInt
class WebtoonImageView @JvmOverloads constructor( class WebtoonImageView @JvmOverloads constructor(
context: Context, context: Context,
@@ -18,7 +20,14 @@ class WebtoonImageView @JvmOverloads constructor(
private val ct = PointF() private val ct = PointF()
private var scrollPos = 0 private var scrollPos = 0
private var scrollRange = SCROLL_UNKNOWN private var debugPaint: Paint? = null
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
if (BuildConfig.DEBUG) {
drawDebug(canvas)
}
}
fun scrollBy(delta: Int) { fun scrollBy(delta: Int) {
val maxScroll = getScrollRange() val maxScroll = getScrollRange()
@@ -41,14 +50,14 @@ class WebtoonImageView @JvmOverloads constructor(
fun getScroll() = scrollPos fun getScroll() = scrollPos
fun getScrollRange(): Int { fun getScrollRange(): Int {
if (scrollRange == SCROLL_UNKNOWN) { if (!isReady) {
computeScrollRange() return 0
} }
return scrollRange.coerceAtLeast(0) val totalHeight = (sHeight * width / sWidth.toFloat()).roundToInt()
return (totalHeight - height).coerceAtLeast(0)
} }
override fun recycle() { override fun recycle() {
scrollRange = SCROLL_UNKNOWN
scrollPos = 0 scrollPos = 0
super.recycle() super.recycle()
} }
@@ -91,8 +100,6 @@ class WebtoonImageView @JvmOverloads constructor(
override fun onDownsamplingChanged() { override fun onDownsamplingChanged() {
super.onDownsamplingChanged() super.onDownsamplingChanged()
adjustScale() adjustScale()
computeScrollRange()
ancestors.firstNotNullOfOrNull { it as? WebtoonRecyclerView }?.updateChildrenScroll()
} }
override fun onReady() { override fun onReady() {
@@ -102,13 +109,10 @@ class WebtoonImageView @JvmOverloads constructor(
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) { override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh) super.onSizeChanged(w, h, oldw, oldh)
if (oldh == h || oldw == 0 || oldh == 0 || scrollRange == SCROLL_UNKNOWN) return if (oldh != h && oldw != 0 && oldh != 0 && isReady) {
ancestors.firstNotNullOfOrNull { it as? WebtoonRecyclerView }?.updateChildrenScroll()
computeScrollRange() } else {
val container = ancestors.firstNotNullOfOrNull { it as? WebtoonFrameLayout } ?: return return
val parentHeight = parentHeight()
if (scrollPos != 0 && container.bottom < parentHeight) {
scrollTo(scrollRange)
} }
} }
@@ -120,14 +124,6 @@ class WebtoonImageView @JvmOverloads constructor(
setScaleAndCenter(minScale, ct) setScaleAndCenter(minScale, ct)
} }
private fun computeScrollRange() {
if (!isReady) {
return
}
val totalHeight = (sHeight * minScale).toIntUp()
scrollRange = (totalHeight - height).coerceAtLeast(0)
}
private fun adjustScale() { private fun adjustScale() {
minScale = width / sWidth.toFloat() minScale = width / sWidth.toFloat()
maxScale = minScale maxScale = minScale
@@ -137,4 +133,18 @@ class WebtoonImageView @JvmOverloads constructor(
private fun parentHeight(): Int { private fun parentHeight(): Int {
return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0 return ancestors.firstNotNullOfOrNull { it as? RecyclerView }?.height ?: 0
} }
private fun drawDebug(canvas: Canvas) {
val paint = debugPaint ?: Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = android.graphics.Color.RED
strokeWidth = context.resources.resolveDp(2f)
textAlign = android.graphics.Paint.Align.LEFT
textSize = context.resources.resolveDp(14f)
debugPaint = this
}
paint.style = Paint.Style.STROKE
canvas.drawRect(1f, 1f, width.toFloat() - 1f, height.toFloat() - 1f, paint)
paint.style = Paint.Style.FILL
canvas.drawText("${getScroll()} / ${getScrollRange()}", 100f, 100f, paint)
}
} }

View File

@@ -3,11 +3,10 @@ package org.koitharu.kotatsu.reader.ui.pager.webtoon
import android.content.Context import android.content.Context
import android.util.AttributeSet import android.util.AttributeSet
import android.view.View import android.view.View
import android.widget.Toast
import androidx.core.view.ViewCompat.TYPE_TOUCH import androidx.core.view.ViewCompat.TYPE_TOUCH
import androidx.core.view.forEach import androidx.core.view.forEach
import androidx.core.view.iterator
import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition import org.koitharu.kotatsu.core.util.ext.findCenterViewPosition
import java.util.LinkedList import java.util.LinkedList
import java.util.WeakHashMap import java.util.WeakHashMap
@@ -18,6 +17,7 @@ class WebtoonRecyclerView @JvmOverloads constructor(
private var onPageScrollListeners: MutableList<OnPageScrollListener>? = null private var onPageScrollListeners: MutableList<OnPageScrollListener>? = null
private val detachedViews = WeakHashMap<View, Unit>() private val detachedViews = WeakHashMap<View, Unit>()
private var isFixingScroll: Boolean = false
override fun onChildDetachedFromWindow(child: View) { override fun onChildDetachedFromWindow(child: View) {
super.onChildDetachedFromWindow(child) super.onChildDetachedFromWindow(child)
@@ -56,6 +56,13 @@ class WebtoonRecyclerView @JvmOverloads constructor(
return consumedY != 0 || dy == 0 return consumedY != 0 || dy == 0
} }
override fun onScrollStateChanged(state: Int) {
super.onScrollStateChanged(state)
if (state == SCROLL_STATE_IDLE) {
updateChildrenScroll()
}
}
private fun consumeVerticalScroll(dy: Int): Int { private fun consumeVerticalScroll(dy: Int): Int {
if (childCount == 0) { if (childCount == 0) {
return 0 return 0
@@ -106,16 +113,12 @@ class WebtoonRecyclerView @JvmOverloads constructor(
} }
private fun notifyScrollChanged(dy: Int) { private fun notifyScrollChanged(dy: Int) {
updateChildrenScroll()
val listeners = onPageScrollListeners val listeners = onPageScrollListeners
if (listeners.isNullOrEmpty()) { if (listeners.isNullOrEmpty()) {
return return
} }
val centerPosition = findCenterViewPosition() val centerPosition = findCenterViewPosition()
listeners.forEach { it.dispatchScroll(this, dy, centerPosition) } listeners.forEach { it.dispatchScroll(this, dy, centerPosition) }
if (BuildConfig.DEBUG) {
validateLayout()
}
} }
fun relayoutChildren() { fun relayoutChildren() {
@@ -128,29 +131,35 @@ class WebtoonRecyclerView @JvmOverloads constructor(
} }
fun updateChildrenScroll() { fun updateChildrenScroll() {
forEach { child -> if (isFixingScroll) {
return
}
isFixingScroll = true
for (child in this) {
val ssiv = (child as WebtoonFrameLayout).target val ssiv = (child as WebtoonFrameLayout).target
when { if (adjustScroll(child, ssiv)) {
child.top < 0 -> ssiv.scrollTo(ssiv.getScrollRange()) break
child.top > 0 -> ssiv.scrollTo(0)
else -> ssiv.scrollBy(0)
} }
} }
isFixingScroll = false
} }
private fun validateLayout() { private fun adjustScroll(child: View, ssiv: WebtoonImageView): Boolean = when {
forEach { child -> child.bottom < height && ssiv.getScroll() < ssiv.getScrollRange() -> {
val ssiv = (child as WebtoonFrameLayout).target val distance = minOf(height - child.bottom, ssiv.getScrollRange() - ssiv.getScroll())
val scroll = ssiv.getScroll() scrollBy(0, -distance)
val assertion = when { ssiv.scrollBy(distance)
child.top < 0 -> scroll == ssiv.getScrollRange() true
child.top > 0 -> scroll == 0
else -> true
}
if (!assertion) {
Toast.makeText(context, "Scroll = $scroll for view with top: ${child.top}", Toast.LENGTH_SHORT).show()
}
} }
child.top > 0 && ssiv.getScroll() > 0 -> {
val distance = minOf(child.top, ssiv.getScroll())
scrollBy(0, distance)
ssiv.scrollBy(-distance)
true
}
else -> false
} }
abstract class OnPageScrollListener { abstract class OnPageScrollListener {

View File

@@ -1,23 +1,23 @@
package org.koitharu.kotatsu.search.ui.suggestion.adapter package org.koitharu.kotatsu.search.ui.suggestion.adapter
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegate import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.databinding.ItemSearchSuggestionTagsBinding
import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem
fun searchSuggestionTagsAD( fun searchSuggestionTagsAD(
listener: SearchSuggestionListener, listener: SearchSuggestionListener,
) = adapterDelegate<SearchSuggestionItem.Tags, SearchSuggestionItem>(R.layout.item_search_suggestion_tags) { ) = adapterDelegateViewBinding<SearchSuggestionItem.Tags, SearchSuggestionItem, ItemSearchSuggestionTagsBinding>(
{ layoutInflater, parent -> ItemSearchSuggestionTagsBinding.inflate(layoutInflater, parent, false) },
) {
val chipGroup = itemView as ChipsView binding.chipsGenres.onChipClickListener = ChipsView.OnChipClickListener { _, data ->
chipGroup.onChipClickListener = ChipsView.OnChipClickListener { _, data ->
listener.onTagClick(data as? MangaTag ?: return@OnChipClickListener) listener.onTagClick(data as? MangaTag ?: return@OnChipClickListener)
} }
bind { bind {
chipGroup.setChips(item.tags) binding.chipsGenres.setChips(item.tags)
} }
} }

View File

@@ -54,7 +54,11 @@ class AppBackupAgent : BackupAgent() {
mtime: Long mtime: Long
) { ) {
if (destination?.name?.endsWith(".bk.zip") == true) { if (destination?.name?.endsWith(".bk.zip") == true) {
restoreBackupFile(data.fileDescriptor, size, BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext))) restoreBackupFile(
data.fileDescriptor,
size,
BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)),
)
destination.delete() destination.delete()
} else { } else {
super.onRestoreFile(data, size, destination, type, mode, mtime) super.onRestoreFile(data, size, destination, type, mode, mtime)
@@ -69,6 +73,7 @@ class AppBackupAgent : BackupAgent() {
backup.put(repository.dumpCategories()) backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites()) backup.put(repository.dumpFavourites())
backup.put(repository.dumpBookmarks()) backup.put(repository.dumpBookmarks())
backup.put(repository.dumpSources())
backup.put(repository.dumpSettings()) backup.put(repository.dumpSettings())
backup.finish() backup.finish()
backup.file backup.file
@@ -86,11 +91,12 @@ class AppBackupAgent : BackupAgent() {
val backup = BackupZipInput(tempFile) val backup = BackupZipInput(tempFile)
try { try {
runBlocking { runBlocking {
backup.getEntry(BackupEntry.HISTORY)?.let { repository.restoreHistory(it) } backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) }
backup.getEntry(BackupEntry.CATEGORIES)?.let { repository.restoreCategories(it) } backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) }
backup.getEntry(BackupEntry.FAVOURITES)?.let { repository.restoreFavourites(it) } backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) }
backup.getEntry(BackupEntry.BOOKMARKS)?.let { repository.restoreBookmarks(it) } backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) }
backup.getEntry(BackupEntry.SETTINGS)?.let { repository.restoreSettings(it) } backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) }
backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) }
} }
} finally { } finally {
backup.close() backup.close()

View File

@@ -0,0 +1,37 @@
package org.koitharu.kotatsu.settings.backup
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCheckableMultipleBinding
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_CHECKED_CHANGED
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
class BackupEntriesAdapter(
clickListener: OnListItemClickListener<BackupEntryModel>,
) : BaseListAdapter<BackupEntryModel>() {
init {
addDelegate(ListItemType.NAV_ITEM, backupEntryAD(clickListener))
}
}
private fun backupEntryAD(
clickListener: OnListItemClickListener<BackupEntryModel>,
) = adapterDelegateViewBinding<BackupEntryModel, BackupEntryModel, ItemCheckableMultipleBinding>(
{ layoutInflater, parent -> ItemCheckableMultipleBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener { v ->
clickListener.onItemClick(item, v)
}
bind { payloads ->
with(binding.root) {
setText(item.titleResId)
setChecked(item.isChecked, PAYLOAD_CHECKED_CHANGED in payloads)
isEnabled = item.isEnabled
}
}
}

View File

@@ -0,0 +1,43 @@
package org.koitharu.kotatsu.settings.backup
import androidx.annotation.StringRes
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.backup.BackupEntry
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
data class BackupEntryModel(
val name: BackupEntry.Name,
val isChecked: Boolean,
val isEnabled: Boolean,
) : ListModel {
@get:StringRes
val titleResId: Int
get() = when (name) {
BackupEntry.Name.INDEX -> 0 // should not appear here
BackupEntry.Name.HISTORY -> R.string.history
BackupEntry.Name.CATEGORIES -> R.string.favourites_categories
BackupEntry.Name.FAVOURITES -> R.string.favourites
BackupEntry.Name.SETTINGS -> R.string.settings
BackupEntry.Name.BOOKMARKS -> R.string.bookmarks
BackupEntry.Name.SOURCES -> R.string.remote_sources
}
override fun areItemsTheSame(other: ListModel): Boolean {
return other is BackupEntryModel && other.name == name
}
override fun getChangePayload(previousState: ListModel): Any? {
if (previousState !is BackupEntryModel) {
return null
}
return if (previousState.isEnabled != isEnabled) {
ListModelDiffCallback.PAYLOAD_ANYTHING_CHANGED
} else if (previousState.isChecked != isChecked) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
}
}
}

View File

@@ -24,21 +24,25 @@ class BackupViewModel @Inject constructor(
init { init {
launchLoadingJob { launchLoadingJob {
val file = BackupZipOutput(context).use { backup -> val file = BackupZipOutput(context).use { backup ->
val step = 1f / 6f
backup.put(repository.createIndex()) backup.put(repository.createIndex())
progress.value = 0f progress.value = 0f
backup.put(repository.dumpHistory()) backup.put(repository.dumpHistory())
progress.value = 0.2f progress.value += step
backup.put(repository.dumpCategories()) backup.put(repository.dumpCategories())
progress.value = 0.4f progress.value += step
backup.put(repository.dumpFavourites()) backup.put(repository.dumpFavourites())
progress.value = 0.6f progress.value += step
backup.put(repository.dumpBookmarks()) backup.put(repository.dumpBookmarks())
progress.value = 0.8f progress.value += step
backup.put(repository.dumpSources())
progress.value += step
backup.put(repository.dumpSettings()) backup.put(repository.dumpSettings())
backup.finish() backup.finish()

View File

@@ -42,6 +42,7 @@ class PeriodicalBackupWorker @AssistedInject constructor(
backup.put(repository.dumpCategories()) backup.put(repository.dumpCategories())
backup.put(repository.dumpFavourites()) backup.put(repository.dumpFavourites())
backup.put(repository.dumpBookmarks()) backup.put(repository.dumpBookmarks())
backup.put(repository.dumpSources())
backup.put(repository.dumpSettings()) backup.put(repository.dumpSettings())
backup.finish() backup.finish()
backup.file backup.file

View File

@@ -3,40 +3,58 @@ package org.koitharu.kotatsu.settings.backup
import android.net.Uri import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.view.LayoutInflater import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup import android.view.ViewGroup
import androidx.core.view.isGone
import androidx.core.view.isVisible import androidx.core.view.isVisible
import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.backup.CompositeResult import org.koitharu.kotatsu.core.backup.CompositeResult
import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogProgressBinding import org.koitharu.kotatsu.databinding.DialogRestoreBinding
import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
import java.text.DateFormat
import java.text.SimpleDateFormat
import java.util.Date
import kotlin.math.roundToInt import kotlin.math.roundToInt
@AndroidEntryPoint @AndroidEntryPoint
class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() { class RestoreDialogFragment : AlertDialogFragment<DialogRestoreBinding>(), OnListItemClickListener<BackupEntryModel>,
View.OnClickListener {
private val viewModel: RestoreViewModel by viewModels() private val viewModel: RestoreViewModel by viewModels()
override fun onCreateViewBinding( override fun onCreateViewBinding(
inflater: LayoutInflater, inflater: LayoutInflater,
container: ViewGroup?, container: ViewGroup?,
) = DialogProgressBinding.inflate(inflater, container, false) ) = DialogRestoreBinding.inflate(inflater, container, false)
override fun onViewBindingCreated(binding: DialogProgressBinding, savedInstanceState: Bundle?) { override fun onViewBindingCreated(binding: DialogRestoreBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState) super.onViewBindingCreated(binding, savedInstanceState)
binding.textViewTitle.setText(R.string.restore_backup) val adapter = BackupEntriesAdapter(this)
binding.textViewSubtitle.setText(R.string.preparing_) binding.recyclerView.adapter = adapter
binding.buttonCancel.setOnClickListener(this)
binding.buttonRestore.setOnClickListener(this)
viewModel.availableEntries.observe(viewLifecycleOwner, adapter)
viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged)
viewModel.onRestoreDone.observeEvent(viewLifecycleOwner, this::onRestoreDone) viewModel.onRestoreDone.observeEvent(viewLifecycleOwner, this::onRestoreDone)
viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) viewModel.onError.observeEvent(viewLifecycleOwner, this::onError)
combine(
viewModel.isLoading,
viewModel.availableEntries,
viewModel.backupDate,
::Triple,
).observe(viewLifecycleOwner, this::onLoadingChanged)
} }
override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
@@ -44,6 +62,40 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
.setCancelable(false) .setCancelable(false)
} }
override fun onClick(v: View) {
when (v.id) {
R.id.button_cancel -> dismiss()
R.id.button_restore -> viewModel.restore()
}
}
override fun onItemClick(item: BackupEntryModel, view: View) {
viewModel.onItemClick(item)
}
private fun onLoadingChanged(value: Triple<Boolean, List<BackupEntryModel>, Date?>) {
val (isLoading, entries, backupDate) = value
val hasEntries = entries.isNotEmpty()
with(requireViewBinding()) {
progressBar.isVisible = isLoading
recyclerView.isGone = isLoading
textViewSubtitle.textAndVisible =
when {
!isLoading -> backupDate?.formatBackupDate()
hasEntries -> getString(R.string.processing_)
else -> getString(R.string.loading_)
}
buttonRestore.isEnabled = !isLoading && entries.any { it.isChecked }
}
}
private fun Date.formatBackupDate(): String {
return getString(
R.string.backup_date_,
SimpleDateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT).format(this),
)
}
private fun onError(e: Throwable) { private fun onError(e: Throwable) {
MaterialAlertDialogBuilder(context ?: return) MaterialAlertDialogBuilder(context ?: return)
.setNegativeButton(R.string.close, null) .setNegativeButton(R.string.close, null)
@@ -89,6 +141,9 @@ class RestoreDialogFragment : AlertDialogFragment<DialogProgressBinding>() {
} }
builder.setPositiveButton(android.R.string.ok, null) builder.setPositiveButton(android.R.string.ok, null)
.show() .show()
if (!result.isEmpty && !result.isAllFailed) {
WelcomeSheet.dismiss(parentFragmentManager)
}
dismiss() dismiss()
} }

View File

@@ -15,8 +15,12 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow 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.toUriOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull
import org.koitharu.kotatsu.parsers.util.SuspendLazy
import java.io.File import java.io.File
import java.io.FileNotFoundException import java.io.FileNotFoundException
import java.util.Date
import java.util.EnumMap
import java.util.EnumSet
import javax.inject.Inject import javax.inject.Inject
@HiltViewModel @HiltViewModel
@@ -26,57 +30,127 @@ class RestoreViewModel @Inject constructor(
@ApplicationContext context: Context, @ApplicationContext context: Context,
) : BaseViewModel() { ) : BaseViewModel() {
private val backupInput = SuspendLazy {
val uri = savedStateHandle.get<String>(RestoreDialogFragment.ARG_FILE)
?.toUriOrNull() ?: throw FileNotFoundException()
val contentResolver = context.contentResolver
runInterruptible(Dispatchers.IO) {
val tempFile = File.createTempFile("backup_", ".tmp")
(contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
}
BackupZipInput(tempFile)
}
}
val progress = MutableStateFlow(-1f) val progress = MutableStateFlow(-1f)
val onRestoreDone = MutableEventFlow<CompositeResult>() val onRestoreDone = MutableEventFlow<CompositeResult>()
val availableEntries = MutableStateFlow<List<BackupEntryModel>>(emptyList())
val backupDate = MutableStateFlow<Date?>(null)
init { init {
launchLoadingJob { launchLoadingJob(Dispatchers.Default) {
val uri = savedStateHandle.get<String>(RestoreDialogFragment.ARG_FILE) val backup = backupInput.get()
?.toUriOrNull() ?: throw FileNotFoundException() val entries = backup.entries()
val contentResolver = context.contentResolver availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry ->
if (entry == BackupEntry.Name.INDEX || entry !in entries) {
val backup = runInterruptible(Dispatchers.IO) { return@mapNotNull null
val tempFile = File.createTempFile("backup_", ".tmp")
(contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input ->
tempFile.outputStream().use { output ->
input.copyTo(output)
}
} }
BackupZipInput(tempFile) BackupEntryModel(
name = entry,
isChecked = true,
isEnabled = true,
)
} }
try { backupDate.value = repository.getBackupDate(backup.getEntry(BackupEntry.Name.INDEX))
val result = CompositeResult() }
}
progress.value = 0f override fun onCleared() {
backup.getEntry(BackupEntry.HISTORY)?.let { super.onCleared()
backupInput.peek()?.cleanupAsync()
}
fun onItemClick(item: BackupEntryModel) {
val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name }
map[item.name] = item.copy(isChecked = !item.isChecked)
map.validate()
availableEntries.value = map.values.sortedBy { it.name.ordinal }
}
fun restore() {
launchLoadingJob {
val backup = backupInput.get()
val checkedItems = availableEntries.value.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) {
if (it.isChecked) it.name else null
}
val result = CompositeResult()
val step = 1f / 6f
progress.value = 0f
if (BackupEntry.Name.HISTORY in checkedItems) {
backup.getEntry(BackupEntry.Name.HISTORY)?.let {
result += repository.restoreHistory(it) result += repository.restoreHistory(it)
} }
}
progress.value = 0.2f progress.value += step
backup.getEntry(BackupEntry.CATEGORIES)?.let { if (BackupEntry.Name.CATEGORIES in checkedItems) {
backup.getEntry(BackupEntry.Name.CATEGORIES)?.let {
result += repository.restoreCategories(it) result += repository.restoreCategories(it)
} }
}
progress.value = 0.4f progress.value += step
backup.getEntry(BackupEntry.FAVOURITES)?.let { if (BackupEntry.Name.FAVOURITES in checkedItems) {
backup.getEntry(BackupEntry.Name.FAVOURITES)?.let {
result += repository.restoreFavourites(it) result += repository.restoreFavourites(it)
} }
}
progress.value = 0.6f progress.value += step
backup.getEntry(BackupEntry.BOOKMARKS)?.let { if (BackupEntry.Name.BOOKMARKS in checkedItems) {
backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let {
result += repository.restoreBookmarks(it) result += repository.restoreBookmarks(it)
} }
}
progress.value = 0.8f progress.value += step
backup.getEntry(BackupEntry.SETTINGS)?.let { if (BackupEntry.Name.SOURCES in checkedItems) {
backup.getEntry(BackupEntry.Name.SOURCES)?.let {
result += repository.restoreSources(it)
}
}
progress.value += step
if (BackupEntry.Name.SETTINGS in checkedItems) {
backup.getEntry(BackupEntry.Name.SETTINGS)?.let {
result += repository.restoreSettings(it) result += repository.restoreSettings(it)
} }
}
progress.value = 1f progress.value = 1f
onRestoreDone.call(result) onRestoreDone.call(result)
} finally { }
backup.close() }
backup.file.delete()
/**
* Check for inconsistent user selection
* Favorites cannot be restored without categories
*/
private fun MutableMap<BackupEntry.Name, BackupEntryModel>.validate() {
val favorites = this[BackupEntry.Name.FAVOURITES] ?: return
val categories = this[BackupEntry.Name.CATEGORIES]
if (categories?.isChecked == true) {
if (!favorites.isEnabled) {
this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = true)
}
} else {
if (favorites.isEnabled) {
this[BackupEntry.Name.FAVOURITES] = favorites.copy(isEnabled = false, isChecked = false)
} }
} }
} }

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center_vertical"
android:orientation="vertical"
android:paddingVertical="?dialogPreferredPadding">
<TextView
android:id="@+id/textView_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="?dialogPreferredPadding"
android:paddingBottom="@dimen/margin_normal"
android:text="@string/restore_backup"
android:textAppearance="?textAppearanceTitleLarge" />
<com.google.android.material.progressindicator.LinearProgressIndicator
android:id="@+id/progressBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="?dialogPreferredPadding"
android:indeterminate="true"
android:max="100"
android:visibility="gone"
tools:visibility="visible" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:scrollIndicators="top|bottom"
android:visibility="gone"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
tools:itemCount="6"
tools:listitem="@layout/item_checkable_multiple"
tools:visibility="visible" />
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="?dialogPreferredPadding"
android:layout_marginTop="@dimen/margin_small"
android:textAppearance="?attr/textAppearanceLabelMedium"
android:textColor="?android:textColorSecondary"
tools:text="@tools:sample/lorem[10]" />
<LinearLayout
style="?buttonBarStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginHorizontal="?dialogPreferredPadding"
android:layout_marginTop="@dimen/margin_normal"
android:gravity="end"
android:orientation="horizontal">
<Button
android:id="@+id/button_cancel"
style="?buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@android:string/cancel" />
<Button
android:id="@+id/button_restore"
style="?buttonBarButtonStyle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/restore" />
</LinearLayout>
</LinearLayout>

View File

@@ -1,165 +1,197 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<ScrollView <FrameLayout
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="match_parent"> android:layout_height="match_parent">
<androidx.constraintlayout.widget.ConstraintLayout <ScrollView
android:id="@+id/scrollView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="wrap_content"
android:scrollIndicators="bottom">
<com.google.android.material.imageview.ShapeableImageView <androidx.constraintlayout.widget.ConstraintLayout
android:id="@+id/imageView_cover" android:layout_width="match_parent"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:background="?colorSecondaryContainer"
android:foreground="?selectableItemBackground"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="H,13:18"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.3"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
tools:background="@tools:sample/backgrounds/scenic[5]"
tools:ignore="ContentDescription,UnusedAttribute" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="16dp" android:paddingBottom="?actionBarSize">
android:layout_marginTop="16dp"
android:ellipsize="end"
android:maxLines="5"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
app:layout_constraintEnd_toStartOf="@id/button_open"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginEnd="16dp"
tools:text="@tools:sample/lorem" />
<ImageButton <com.google.android.material.imageview.ShapeableImageView
android:id="@+id/button_open" android:id="@+id/imageView_cover"
android:layout_width="0dp"
android:layout_height="0dp"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:background="?colorSecondaryContainer"
android:foreground="?selectableItemBackground"
android:scaleType="centerCrop"
app:layout_constraintDimensionRatio="H,13:18"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:layout_constraintWidth_percent="0.3"
app:shapeAppearanceOverlay="@style/ShapeAppearanceOverlay.Kotatsu.Cover"
tools:background="@tools:sample/backgrounds/scenic[5]"
tools:ignore="ContentDescription,UnusedAttribute" />
<TextView
android:id="@+id/textView_title"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="16dp"
android:ellipsize="end"
android:maxLines="5"
android:textAppearance="?attr/textAppearanceHeadlineSmall"
app:layout_constraintEnd_toStartOf="@id/button_open"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toTopOf="parent"
app:layout_goneMarginEnd="16dp"
tools:text="@tools:sample/lorem" />
<ImageButton
android:id="@+id/button_open"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/details"
android:padding="12dp"
app:layout_constraintEnd_toStartOf="@id/button_close"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="@drawable/ic_expand" />
<ImageButton
android:id="@+id/button_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/close"
android:padding="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="?actionModeCloseDrawable"
app:tint="?colorControlNormal" />
<TextView
android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="3"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_title"
tools:text="@tools:sample/lorem[12]" />
<TextView
android:id="@+id/textView_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:background="@drawable/list_selector"
android:padding="2dp"
android:singleLine="true"
android:textColor="?attr/colorTertiary"
android:textStyle="bold"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_subtitle"
tools:text="@tools:sample/full_names" />
<RatingBar
android:id="@+id/rating_bar"
style="?ratingBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:isIndicator="true"
android:max="1"
android:numStars="5"
android:stepSize="0.5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_author"
tools:rating="4" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_header"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="imageView_cover,rating_bar" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_tags"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:chipSpacingHorizontal="6dp"
app:chipSpacingVertical="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/barrier_header" />
<org.koitharu.kotatsu.core.ui.widgets.SelectableTextView
android:id="@+id/textView_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="@dimen/margin_normal"
android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chips_tags"
tools:ignore="UnusedAttribute"
tools:text="@tools:sample/lorem/random" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>
<com.google.android.material.appbar.MaterialToolbar
android:id="@+id/toolbar_bottom"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:background="?colorBackgroundFloating">
<com.google.android.material.button.MaterialButton
android:id="@+id/button_read"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_gravity="end|center_vertical"
android:background="?selectableItemBackgroundBorderless" android:layout_marginHorizontal="@dimen/toolbar_button_margin"
android:contentDescription="@string/details" android:enabled="false"
android:padding="12dp" android:text="@string/read"
app:layout_constraintEnd_toStartOf="@id/button_close" android:textAllCaps="false"
app:layout_constraintTop_toTopOf="parent" app:iconGravity="textStart"
app:srcCompat="@drawable/ic_expand" /> app:iconPadding="8dp"
tools:enabled="true"
tools:icon="@drawable/ic_read" />
<ImageButton </com.google.android.material.appbar.MaterialToolbar>
android:id="@+id/button_close"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:layout_marginEnd="4dp"
android:background="?selectableItemBackgroundBorderless"
android:contentDescription="@string/close"
android:padding="12dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent"
app:srcCompat="?actionModeCloseDrawable"
app:tint="?colorControlNormal" />
<TextView </FrameLayout>
android:id="@+id/textView_subtitle"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:ellipsize="end"
android:maxLines="3"
android:textAppearance="?attr/textAppearanceBodyMedium"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_title"
tools:text="@tools:sample/lorem[12]" />
<TextView
android:id="@+id/textView_author"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:background="@drawable/list_selector"
android:padding="2dp"
android:singleLine="true"
android:textColor="?attr/colorTertiary"
android:textStyle="bold"
app:layout_constrainedWidth="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_subtitle"
tools:text="@tools:sample/full_names" />
<RatingBar
android:id="@+id/rating_bar"
style="?ratingBarStyleSmall"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginTop="4dp"
android:layout_marginEnd="16dp"
android:isIndicator="true"
android:max="1"
android:numStars="5"
android:stepSize="0.5"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="0.0"
app:layout_constraintStart_toEndOf="@id/imageView_cover"
app:layout_constraintTop_toBottomOf="@id/textView_author"
tools:rating="4" />
<androidx.constraintlayout.widget.Barrier
android:id="@+id/barrier_header"
android:layout_width="0dp"
android:layout_height="wrap_content"
app:barrierDirection="bottom"
app:barrierMargin="8dp"
app:constraint_referenced_ids="imageView_cover,rating_bar" />
<org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:id="@+id/chips_tags"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:paddingStart="16dp"
android:paddingEnd="16dp"
app:chipSpacingHorizontal="6dp"
app:chipSpacingVertical="6dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/barrier_header" />
<org.koitharu.kotatsu.core.ui.widgets.SelectableTextView
android:id="@+id/textView_description"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="@dimen/margin_normal"
android:layout_marginTop="@dimen/margin_small"
android:layout_marginEnd="@dimen/margin_normal"
android:lineSpacingMultiplier="1.2"
android:paddingBottom="@dimen/margin_normal"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textIsSelectable="true"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/chips_tags"
tools:ignore="UnusedAttribute"
tools:text="@tools:sample/lorem/random" />
</androidx.constraintlayout.widget.ConstraintLayout>
</ScrollView>

View File

@@ -1,34 +1,34 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<LinearLayout <HorizontalScrollView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:scrollbars="none">
android:orientation="horizontal"
android:paddingHorizontal="12dp"
android:paddingVertical="8dp"
app:layout_scrollFlags="scroll|exitUntilCollapsed">
<Button <com.google.android.material.chip.ChipGroup
android:id="@+id/button_create"
style="@style/Widget.Material3.Button.OutlinedButton"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:ellipsize="end" android:paddingStart="?listPreferredItemPaddingStart"
android:singleLine="true" android:paddingEnd="?listPreferredItemPaddingEnd"
android:text="@string/create_category" app:singleLine="true">
app:icon="@drawable/ic_add" />
<Button <com.google.android.material.chip.Chip
android:id="@+id/button_manage" android:id="@+id/chip_create"
style="@style/Widget.Material3.Button.OutlinedButton" style="@style/Widget.Kotatsu.Chip.Assist"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginStart="8dp" android:text="@string/create_category"
android:ellipsize="end" app:chipIcon="@drawable/ic_add" />
android:singleLine="true"
android:text="@string/manage"
app:icon="@drawable/ic_edit" />
</LinearLayout> <com.google.android.material.chip.Chip
android:id="@+id/chip_manage"
style="@style/Widget.Kotatsu.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/manage_categories"
app:chipIcon="@drawable/ic_edit" />
</com.google.android.material.chip.ChipGroup>
</HorizontalScrollView>

View File

@@ -5,6 +5,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall" android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?selectableItemBackground"
android:drawablePadding="?android:listPreferredItemPaddingStart" android:drawablePadding="?android:listPreferredItemPaddingStart"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingStart="?android:listPreferredItemPaddingStart" android:paddingStart="?android:listPreferredItemPaddingStart"

View File

@@ -5,6 +5,7 @@
xmlns:tools="http://schemas.android.com/tools" xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="?android:listPreferredItemHeightSmall" android:layout_height="?android:listPreferredItemHeightSmall"
android:background="?selectableItemBackground"
android:drawablePadding="?android:listPreferredItemPaddingStart" android:drawablePadding="?android:listPreferredItemPaddingStart"
android:gravity="center_vertical" android:gravity="center_vertical"
android:paddingStart="?android:listPreferredItemPaddingStart" android:paddingStart="?android:listPreferredItemPaddingStart"

View File

@@ -1,9 +1,18 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<org.koitharu.kotatsu.core.ui.widgets.ChipsView <HorizontalScrollView
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/scrollView"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingStart="?listPreferredItemPaddingStart" android:scrollbars="none">
android:paddingTop="4dp"
android:paddingEnd="?listPreferredItemPaddingEnd" <org.koitharu.kotatsu.core.ui.widgets.ChipsView
android:paddingBottom="6dp" /> android:id="@+id/chips_genres"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingStart="?listPreferredItemPaddingStart"
android:paddingEnd="?listPreferredItemPaddingEnd"
app:singleLine="true" />
</HorizontalScrollView>

View File

@@ -20,6 +20,7 @@
android:scrollIndicators="top"> android:scrollIndicators="top">
<LinearLayout <LinearLayout
android:id="@+id/layout_body"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="vertical" android:orientation="vertical"

View File

@@ -17,18 +17,19 @@
<com.google.android.material.textfield.TextInputLayout <com.google.android.material.textfield.TextInputLayout
android:id="@+id/text_input_layout" android:id="@+id/text_input_layout"
style="?textInputFilledDenseStyle"
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="wrap_content" android:layout_height="wrap_content"
app:startIconDrawable="?android:actionModeWebSearchDrawable"
app:endIconMode="clear_text"
app:endIconDrawable="@drawable/abc_ic_clear_material"
android:hint="@string/genres_search_hint"
android:layout_marginHorizontal="16dp" android:layout_marginHorizontal="16dp"
android:layout_marginBottom="4dp" android:layout_marginBottom="4dp"
android:hint="@string/genres_search_hint"
app:endIconDrawable="@drawable/abc_ic_clear_material"
app:endIconMode="clear_text"
app:layout_constraintBottom_toTopOf="@id/recyclerView"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/headerBar" app:layout_constraintTop_toBottomOf="@id/headerBar"
app:layout_constraintBottom_toTopOf="@id/recyclerView"> app:startIconDrawable="?android:actionModeWebSearchDrawable">
<com.google.android.material.textfield.TextInputEditText <com.google.android.material.textfield.TextInputEditText
android:id="@+id/edit_search" android:id="@+id/edit_search"
@@ -36,7 +37,7 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:imeOptions="actionSearch|flagNoFullscreen" android:imeOptions="actionSearch|flagNoFullscreen"
android:importantForAutofill="no" android:importantForAutofill="no"
android:inputType="text"/> android:inputType="text" />
</com.google.android.material.textfield.TextInputLayout> </com.google.android.material.textfield.TextInputLayout>

View File

@@ -539,4 +539,8 @@
<string name="error_filter_locale_genre_not_supported">Esta fuente no permite filtrar por géneros ni por configuración regional</string> <string name="error_filter_locale_genre_not_supported">Esta fuente no permite filtrar por géneros ni por configuración regional</string>
<string name="genres_search_hint">Empieza a escribir el nombre del género</string> <string name="genres_search_hint">Empieza a escribir el nombre del género</string>
<string name="disable_battery_optimization_summary_downloads">Puede que te ayude a iniciar la descarga si tienes algún problema</string> <string name="disable_battery_optimization_summary_downloads">Puede que te ayude a iniciar la descarga si tienes algún problema</string>
<string name="welcome_text">Por favor, seleccione las fuentes de contenido que desea habilitar. Esto también se puede configurar más tarde en la configuración</string>
<string name="restore">Restaurar</string>
<string name="backup_date_">Fecha de la copia de seguridad: %s</string>
<string name="sync_auth">Iniciar sesión en la cuenta de sincronización</string>
</resources> </resources>

View File

@@ -540,4 +540,7 @@
<string name="grayscale">Оттенки серого</string> <string name="grayscale">Оттенки серого</string>
<string name="disable_battery_optimization_summary_downloads">Может помочь с запуском загрузки, если у вас с этим возникают проблемы</string> <string name="disable_battery_optimization_summary_downloads">Может помочь с запуском загрузки, если у вас с этим возникают проблемы</string>
<string name="sync_auth">Войти в аккаунт синхронизации</string> <string name="sync_auth">Войти в аккаунт синхронизации</string>
<string name="welcome_text">Пожалуйста, выберите, какие источники вы хотите включить. Это можно изменить в настройках позже</string>
<string name="restore">Восстановить</string>
<string name="backup_date_">Дата резервного копирования: %s</string>
</resources> </resources>

View File

@@ -378,4 +378,9 @@
<string name="skip">ข้าม</string> <string name="skip">ข้าม</string>
<string name="state_paused">หยุดชั่วคราว</string> <string name="state_paused">หยุดชั่วคราว</string>
<string name="content_type_other">อื่น</string> <string name="content_type_other">อื่น</string>
<string name="pages_cache">แคชหน้า</string>
<string name="reader_info_pattern">บท. %1$d/%2$d หน้า. %3$d/%4$d</string>
<string name="queued">เพิ่มเข้าคิวแล้ว</string>
<string name="exit_confirmation">ยืนยันการออก</string>
<string name="reorder">เรียงลำดับ</string>
</resources> </resources>

View File

@@ -541,4 +541,6 @@
<string name="grayscale">Gri tonlamalı</string> <string name="grayscale">Gri tonlamalı</string>
<string name="welcome_text">Lütfen hangi içerik kaynaklarını etkinleştirmek istediğinizi seçin. Bu daha sonra ayarlardan da yapılandırılabilir</string> <string name="welcome_text">Lütfen hangi içerik kaynaklarını etkinleştirmek istediğinizi seçin. Bu daha sonra ayarlardan da yapılandırılabilir</string>
<string name="sync_auth">Eşitleme hesabında oturum aç</string> <string name="sync_auth">Eşitleme hesabında oturum aç</string>
<string name="restore">Geri yükle</string>
<string name="backup_date_">Yedekleme tarihi: %s</string>
</resources> </resources>

View File

@@ -546,4 +546,6 @@
<string name="disable_battery_optimization_summary_downloads">Might help with getting the download started if you have any issues with it</string> <string name="disable_battery_optimization_summary_downloads">Might help with getting the download started if you have any issues with it</string>
<string name="welcome_text">Please select which content sources you would like to enable. This can also be configured later in settings</string> <string name="welcome_text">Please select which content sources you would like to enable. This can also be configured later in settings</string>
<string name="sync_auth">Login to sync account</string> <string name="sync_auth">Login to sync account</string>
<string name="restore">Restore</string>
<string name="backup_date_">Backup date: %s</string>
</resources> </resources>