diff --git a/.github/assets/vtuber.png b/.github/assets/vtuber.png new file mode 100644 index 000000000..f5751f904 Binary files /dev/null and b/.github/assets/vtuber.png differ diff --git a/.gitignore b/.gitignore index 13c83266d..812fcafc9 100644 --- a/.gitignore +++ b/.gitignore @@ -26,4 +26,4 @@ .cxx /.idea/deviceManager.xml /.kotlin/ -/.idea/AndroidProjectSystem.xml +/.idea/AndroidProjectSystem.xml \ No newline at end of file diff --git a/.idea/AndroidProjectSystem.xml b/.idea/AndroidProjectSystem.xml new file mode 100644 index 000000000..4a53bee8c --- /dev/null +++ b/.idea/AndroidProjectSystem.xml @@ -0,0 +1,6 @@ + + + + + \ No newline at end of file diff --git a/.idea/gradle.xml b/.idea/gradle.xml index 7b3006b6e..d124cf2a6 100644 --- a/.idea/gradle.xml +++ b/.idea/gradle.xml @@ -6,14 +6,13 @@ - diff --git a/README.md b/README.md index e0236e062..e5a138ad0 100644 --- a/README.md +++ b/README.md @@ -1,57 +1,107 @@ -# Kotatsu +
-Kotatsu is a free and open-source manga reader for Android with built-in online content sources. + + Kotatsu Logo + -[![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) ![F-Droid Version](https://img.shields.io/f-droid/v/org.koitharu.kotatsu) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF)](https://t.me/kotatsuapp) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE) +# [Kotatsu](https://kotatsu.app) + +**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is a free and open-source manga reader for Android with built-in online content sources.** + +![Downloads count](https://img.shields.io/github/downloads/KotatsuApp/Kotatsu/total?color=1976d2) ![Latest Stable version](https://img.shields.io/github/v/release/KotatsuApp/Kotatsu?color=2596be&label=latest) ![Android 5.0](https://img.shields.io/badge/android-5.0+-brightgreen) [![Sources count](https://img.shields.io/badge/dynamic/yaml?url=https%3A%2F%2Fraw.githubusercontent.com%2FKotatsuApp%2Fkotatsu-parsers%2Frefs%2Fheads%2Fmaster%2F.github%2Fsummary.yaml&query=total&label=manga%20sources&color=%23E9321C)](https://github.com/KotatsuApp/kotatsu-parsers) [![weblate](https://hosted.weblate.org/widgets/kotatsu/-/strings/svg-badge.svg)](https://hosted.weblate.org/engage/kotatsu/) [![Discord](https://img.shields.io/discord/898363402467045416?color=5865f2&label=discord)](https://discord.gg/NNJ5RgVBC5) [![Telegram](https://img.shields.io/badge/chat-telegram-60ACFF?)](https://t.me/kotatsuapp) [![License](https://img.shields.io/github/license/KotatsuApp/Kotatsu)](https://github.com/KotatsuApp/Kotatsu/blob/devel/LICENSE) ### Download -- **Recommended:** Download and install APK from **[GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest)**. Application has a built-in self-updating feature. -- Get it on **[F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu)**. The F-Droid build may be a bit outdated and some fixes might be missing. -- Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk). +
+ +* **Recommended:** Download and install APK from [GitHub Releases](https://github.com/KotatsuApp/Kotatsu/releases/latest). Application has a built-in self-updating feature. +* Get it on [F-Droid](https://f-droid.org/packages/org.koitharu.kotatsu). The F-Droid build may be a bit outdated and some fixes might be missing. +* Also [nightly builds](https://github.com/KotatsuApp/Kotatsu-nightly/releases) are available (very unstable, use at your own risk). + +
### Main Features -* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) +
+ +* Online [manga catalogues](https://github.com/KotatsuApp/kotatsu-parsers) (with 1100+ manga sources) * Search manga by name, genres, and more filters -* Reading history and bookmarks * Favorites organized by user-defined categories -* Downloading manga and reading it offline. Third-party CBZ archives also supported -* Tablet-optimized Material You UI -* Standard and Webtoon-optimized customizable reader -* Notifications about new chapters with updates feed +* Reading history, bookmarks, and incognito mode support +* Download manga and read it offline. Third-party CBZ archives are also supported +* Clean and convenient Material You UI, optimized for phones, tablets, and desktop +* Standard and Webtoon-optimized customizable reader, gesture support on reading interface +* Notifications about new chapters with updates feed, manga recommendations (with filters) * Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu -* Password/fingerprint-protected access to the app +* Password / fingerprint-protected access to the app +* Automatically sync app data with other devices on the same account +* Support for older devices running Android 5+ + +
+ +### In-App Screenshots -### Screenshots +
+ Mobile view + Mobile view + Mobile view + Mobile view + Mobile view + Mobile view +
-| ![Screenshot_20200226-210337](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/1.png) | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/2.png) | ![Screenshot_20200226-210232](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/3.png) | -|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------| -| ![Screenshot_20200226-210405](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/4.png) | ![Screenshot_20200226-210151](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/5.png) | ![Screenshot_20200226-210223](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/phoneScreenshots/6.png) | +
-| ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/1.png) | ![](https://github.com/KotatsuApp/Kotatsu/raw/devel/metadata/en-US/images/tenInchScreenshots/2.png) | -|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------| +
+ Tablet view + Tablet view +
### Localization -[Translation status](https://hosted.weblate.org/engage/kotatsu/) + +Translation status + -Kotatsu is localized in a number of different languages, if you would like to help improve these or add new languages, -please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/) +**[Kotatsu](https://github.com/KotatsuApp/Kotatsu) is localized in a number of different languages.**
+**📌 If you would like to help improve these or add new languages, +please head over to the [Weblate project page](https://hosted.weblate.org/engage/kotatsu/)** ### Contributing -See [CONTRIBUTING.md](./CONTRIBUTING.md) for the guidelines. +
+ + + + + Kotatsu GitHub Repository + + + + + + Kotatsu-parsers GitHub Repository + +

+ +
+ +**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines** ### License [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) -You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications -to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & -install instructions. +
+ +You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. + +
### DMCA disclaimer -The developers of this application do not have any affiliation with the content available in the app. -It collects content from sources that are freely available through any web browser +
+ +The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser. + +
diff --git a/app/build.gradle b/app/build.gradle index e7559638a..58d2b7b72 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -7,6 +7,7 @@ plugins { id 'com.google.devtools.ksp' id 'kotlin-parcelize' id 'dagger.hilt.android.plugin' + id 'androidx.room' } android { @@ -18,13 +19,12 @@ android { applicationId 'org.koitharu.kotatsu' minSdk = 21 targetSdk = 35 - versionCode = 703 - versionName = '7.7.11' + versionCode = 1004 + versionName = '8.0' generatedDensities = [] testInstrumentationRunner 'org.koitharu.kotatsu.HiltTestRunner' ksp { arg('room.generateKotlin', 'true') - arg('room.schemaLocation', "$projectDir/schemas") } androidResources { generateLocaleConfig true @@ -74,8 +74,12 @@ android { '-opt-in=kotlinx.coroutines.FlowPreview', '-opt-in=kotlin.contracts.ExperimentalContracts', '-opt-in=coil3.annotation.ExperimentalCoilApi', + '-opt-in=coil3.annotation.InternalCoilApi', ] } + room { + schemaDirectory "$projectDir/schemas" + } lint { abortOnError true disable 'MissingTranslation', 'PrivateResource', 'SetJavaScriptEnabled', 'SimpleDateFormat' diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt index 1e9dd8a44..bd5169044 100644 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt +++ b/app/src/debug/kotlin/org/koitharu/kotatsu/KotatsuApp.kt @@ -1,9 +1,12 @@ package org.koitharu.kotatsu import android.content.Context +import android.content.SharedPreferences import android.os.Build import android.os.StrictMode +import androidx.core.content.edit import androidx.fragment.app.strictmode.FragmentStrictMode +import leakcanary.LeakCanary import org.koitharu.kotatsu.core.BaseApp import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.PagesCache @@ -13,9 +16,23 @@ import org.koitharu.kotatsu.reader.ui.ReaderViewModel class KotatsuApp : BaseApp() { + var isLeakCanaryEnabled: Boolean + get() = getDebugPreferences(this).getBoolean(KEY_LEAK_CANARY, true) + set(value) { + getDebugPreferences(this).edit { putBoolean(KEY_LEAK_CANARY, value) } + configureLeakCanary() + } + override fun attachBaseContext(base: Context) { super.attachBaseContext(base) enableStrictMode() + configureLeakCanary() + } + + private fun configureLeakCanary() { + LeakCanary.config = LeakCanary.config.copy( + dumpHeap = isLeakCanaryEnabled, + ) } private fun enableStrictMode() { @@ -55,7 +72,7 @@ class KotatsuApp : BaseApp() { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P && notifier != null) { penaltyListener(notifier.executor, notifier) } - }.build() + }.build(), ) FragmentStrictMode.defaultPolicy = FragmentStrictMode.Policy.Builder().apply { detectWrongFragmentContainer() @@ -70,4 +87,13 @@ class KotatsuApp : BaseApp() { } }.build() } + + private companion object { + + const val PREFS_DEBUG = "_debug" + const val KEY_LEAK_CANARY = "leak_canary" + + fun getDebugPreferences(context: Context): SharedPreferences = + context.getSharedPreferences(PREFS_DEBUG, MODE_PRIVATE) + } } diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/StrictModeNotifier.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/StrictModeNotifier.kt index 058e53c09..b200b051e 100644 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/StrictModeNotifier.kt +++ b/app/src/debug/kotlin/org/koitharu/kotatsu/StrictModeNotifier.kt @@ -55,7 +55,7 @@ class StrictModeNotifier( .setContentIntent( PendingIntentCompat.getActivity( context, - 0, + violation.hashCode(), ShareHelper(context).getShareTextIntent(violation.stackTraceToString()), 0, false, diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt new file mode 100644 index 000000000..b98c6cdb6 --- /dev/null +++ b/app/src/debug/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt @@ -0,0 +1,21 @@ +package org.koitharu.kotatsu.core.ui + +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleService +import leakcanary.AppWatcher + +abstract class BaseService : LifecycleService() { + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(ContextCompat.getContextForLanguage(newBase)) + } + + override fun onDestroy() { + super.onDestroy() + AppWatcher.objectWatcher.watch( + watchedObject = this, + description = "${javaClass.simpleName} service received Service#onDestroy() callback", + ) + } +} diff --git a/app/src/debug/kotlin/org/koitharu/kotatsu/settings/SettingsMenuProvider.kt b/app/src/debug/kotlin/org/koitharu/kotatsu/settings/SettingsMenuProvider.kt index 519db2319..63bf3ef15 100644 --- a/app/src/debug/kotlin/org/koitharu/kotatsu/settings/SettingsMenuProvider.kt +++ b/app/src/debug/kotlin/org/koitharu/kotatsu/settings/SettingsMenuProvider.kt @@ -6,6 +6,7 @@ import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import leakcanary.LeakCanary +import org.koitharu.kotatsu.KotatsuApp import org.koitharu.kotatsu.R import org.koitharu.workinspector.WorkInspector @@ -13,10 +14,18 @@ class SettingsMenuProvider( private val context: Context, ) : MenuProvider { + private val application: KotatsuApp + get() = context.applicationContext as KotatsuApp + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_settings, menu) } + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem(R.id.action_leakcanary).isChecked = application.isLeakCanaryEnabled + } + override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_leaks -> { context.startActivity(LeakCanary.newLeakDisplayActivityIntent()) @@ -28,6 +37,13 @@ class SettingsMenuProvider( true } + R.id.action_leakcanary -> { + val checked = !menuItem.isChecked + menuItem.isChecked = checked + application.isLeakCanaryEnabled = checked + true + } + else -> false } } diff --git a/app/src/debug/res/menu/opt_settings.xml b/app/src/debug/res/menu/opt_settings.xml index b7bb4a5ac..8f3a0a518 100644 --- a/app/src/debug/res/menu/opt_settings.xml +++ b/app/src/debug/res/menu/opt_settings.xml @@ -1,15 +1,23 @@ + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 610785d48..840fb88d3 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -49,6 +49,7 @@ android:enableOnBackInvokedCallback="@bool/is_predictive_back_enabled" android:fullBackupContent="@xml/backup_content" android:fullBackupOnly="true" + android:hasFragileUserData="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" android:largeHeap="true" @@ -280,6 +281,10 @@ + = invoke(manga, MATCH_THRESHOLD_DEFAULT) - - suspend operator fun invoke(manga: Manga, matchThreshold: Float): Flow { - val sources = getSources(manga.source) + suspend operator fun invoke(manga: Manga, throughDisabledSources: Boolean): Flow { + val sources = getSources(manga.source, throughDisabledSources) if (sources.isEmpty()) { return emptyFlow() } val semaphore = Semaphore(MAX_PARALLELISM) return channelFlow { for (source in sources) { - val repository = mangaRepositoryFactory.create(source) - if (!repository.filterCapabilities.isSearchSupported) { - continue - } launch { + val searchHelper = searchHelperFactory.create(source) val list = runCatchingCancellable { semaphore.withPermit { - repository.getList(offset = 0, SortOrder.RELEVANCE, MangaListFilter(query = manga.title)) + searchHelper(manga.title, SearchKind.TITLE)?.manga } - }.getOrDefault(emptyList()) - for (item in list) { - if (item.matches(manga, matchThreshold)) { - send(item) + }.getOrNull() + list?.forEach { m -> + if (m.id != manga.id) { + launch { + val details = runCatchingCancellable { + mangaRepositoryFactory.create(m.source).getDetails(m) + }.getOrDefault(m) + send(details) + } } } } } - }.map { - runCatchingCancellable { - mangaRepositoryFactory.create(it.source).getDetails(it) - }.getOrDefault(it) } } - private suspend fun getSources(ref: MangaSource): List { - val result = ArrayList(MangaParserSource.entries.size - 2) - result.addAll(sourcesRepository.getEnabledSources()) - result.sortByDescending { it.priority(ref) } - result.addAll(sourcesRepository.getDisabledSources().sortedByDescending { it.priority(ref) }) - return result - } - - private fun Manga.matches(ref: Manga, threshold: Float): Boolean { - return matchesTitles(title, ref.title, threshold) || - matchesTitles(title, ref.altTitle, threshold) || - matchesTitles(altTitle, ref.title, threshold) || - matchesTitles(altTitle, ref.altTitle, threshold) - - } - - private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean { - return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold) - } + private suspend fun getSources(ref: MangaSource, disabled: Boolean): List = if (disabled) { + sourcesRepository.getDisabledSources() + } else { + sourcesRepository.getEnabledSources() + }.sortedByDescending { it.priority(ref) } private fun MangaSource.priority(ref: MangaSource): Int { var res = 0 if (this is MangaParserSource && ref is MangaParserSource) { - if (locale == ref.locale) res += 2 - if (contentType == ref.contentType) res++ + if (locale == ref.locale) { + res += 4 + } else if (locale.toLocale() == Locale.getDefault()) { + res += 2 + } + if (contentType == ref.contentType) { + res++ + } } return res } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt index ec45258c9..6a4f3aa58 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/domain/AutoFixUseCase.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.model.chaptersCount import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.util.ext.concat import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.util.concurrent.TimeUnit @@ -29,12 +30,14 @@ class AutoFixUseCase @Inject constructor( ) { suspend operator fun invoke(mangaId: Long): Pair { - val seed = checkNotNull(mangaDataRepository.findMangaById(mangaId)) { "Manga $mangaId not found" } - .getDetailsSafe() + val seed = checkNotNull( + mangaDataRepository.findMangaById(mangaId, withChapters = true), + ) { "Manga $mangaId not found" }.getDetailsSafe() if (seed.isHealthy()) { return seed to null // no fix required } - val replacement = alternativesUseCase(seed, matchThreshold = 0.02f) + val replacement = alternativesUseCase(seed, throughDisabledSources = false) + .concat(alternativesUseCase(seed, throughDisabledSources = true)) .filter { it.isHealthy() } .runningFold(null) { best, candidate -> if (best == null || best < candidate) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativeAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativeAD.kt index 74afdba6a..3575ded7b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativeAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativeAD.kt @@ -4,6 +4,7 @@ import android.text.style.ForegroundColorSpan import androidx.core.content.ContextCompat import androidx.core.text.buildSpannedString import androidx.core.text.inSpans +import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import coil3.ImageLoader import coil3.request.ImageRequest @@ -26,6 +27,7 @@ import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.mangaExtra import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest @@ -51,10 +53,22 @@ fun alternativeAD( binding.chipSource.setOnClickListener(clickListener) bind { payloads -> - binding.textViewTitle.text = item.manga.title + binding.textViewTitle.text = item.mangaModel.title + with(binding.iconsView) { + clearIcons() + if (item.mangaModel.isSaved) addIcon(R.drawable.ic_storage) + if (item.mangaModel.isFavorite) addIcon(R.drawable.ic_heart_outline) + isVisible = iconsCount > 0 + } binding.textViewSubtitle.text = buildSpannedString { if (item.chaptersCount > 0) { - append(context.resources.getQuantityString(R.plurals.chapters, item.chaptersCount, item.chaptersCount)) + append( + context.resources.getQuantityStringSafe( + R.plurals.chapters, + item.chaptersCount, + item.chaptersCount, + ), + ) } else { append(context.getString(R.string.no_chapters)) } @@ -70,7 +84,10 @@ fun alternativeAD( } } } - binding.progressView.setProgress(item.progress, ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads) + binding.progressView.setProgress( + item.mangaModel.progress, + ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads, + ) binding.chipSource.also { chip -> chip.text = item.manga.source.getTitle(chip.context) ImageRequest.Builder(context) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesActivity.kt index 22a918928..d3b022ee4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesActivity.kt @@ -1,41 +1,40 @@ package org.koitharu.kotatsu.alternatives.ui -import android.content.Context -import android.content.Intent import android.os.Bundle import android.view.View import android.widget.Toast import androidx.activity.viewModels -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.getTitle -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.ListItemType +import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration +import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.search.ui.MangaListActivity import javax.inject.Inject @AndroidEntryPoint class AlternativesActivity : BaseActivity(), + ListStateHolderListener, OnListItemClickListener { @Inject @@ -55,6 +54,7 @@ class AlternativesActivity : BaseActivity(), .addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, this, null)) .addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) .addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) + .addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(this)) with(viewBinding.recyclerView) { setHasFixedSize(true) addItemDecoration(TypedListSpacingDecoration(context, addHorizontalPadding = false)) @@ -62,39 +62,46 @@ class AlternativesActivity : BaseActivity(), } viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - viewModel.content.observe(this, listAdapter) + viewModel.list.observe(this, listAdapter) viewModel.onMigrated.observeEvent(this) { Toast.makeText(this, R.string.migration_completed, Toast.LENGTH_SHORT).show() - startActivity(DetailsActivity.newIntent(this, it)) + router.openDetails(it) finishAfterTransition() } } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets viewBinding.recyclerView.updatePadding( - bottom = insets.bottom + viewBinding.recyclerView.paddingTop, + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + viewBinding.appbar.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + top = barsInsets.top, ) + return insets.consumeAllSystemBarsInsets() } override fun onItemClick(item: MangaAlternativeModel, view: View) { when (view.id) { - R.id.chip_source -> startActivity( - MangaListActivity.newIntent( - this, - item.manga.source, - MangaListFilter(query = viewModel.manga.title), - ), - ) - + R.id.chip_source -> router.openSearch(item.manga.source, viewModel.manga.title) R.id.button_migrate -> confirmMigration(item.manga) - else -> startActivity(DetailsActivity.newIntent(this, item.manga)) + else -> router.openDetails(item.manga) } } + override fun onRetryClick(error: Throwable) = viewModel.retry() + + override fun onEmptyActionClick() = Unit + + override fun onFooterButtonClick() = viewModel.continueSearch() + private fun confirmMigration(target: Manga) { buildAlertDialog(this, isCentered = true) { setIcon(R.drawable.ic_replace) @@ -114,10 +121,4 @@ class AlternativesActivity : BaseActivity(), } }.show() } - - companion object { - - fun newIntent(context: Context, manga: Manga) = Intent(context, AlternativesActivity::class.java) - .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt index 918227088..5b3b37825 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AlternativesViewModel.kt @@ -1,33 +1,40 @@ package org.koitharu.kotatsu.alternatives.ui import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEmpty -import kotlinx.coroutines.flow.runningFold +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.alternatives.domain.AlternativesUseCase import org.koitharu.kotatsu.alternatives.domain.MigrateUseCase import org.koitharu.kotatsu.core.model.chaptersCount import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaRepository -import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.append import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.require -import org.koitharu.kotatsu.history.data.HistoryRepository -import org.koitharu.kotatsu.list.domain.ReadingProgress +import org.koitharu.kotatsu.list.domain.MangaListMapper +import org.koitharu.kotatsu.list.ui.model.ButtonFooter import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrDefault +import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import javax.inject.Inject @HiltViewModel @@ -36,46 +43,67 @@ class AlternativesViewModel @Inject constructor( private val mangaRepositoryFactory: MangaRepository.Factory, private val alternativesUseCase: AlternativesUseCase, private val migrateUseCase: MigrateUseCase, - private val historyRepository: HistoryRepository, - private val settings: AppSettings, + private val mangaListMapper: MangaListMapper, ) : BaseViewModel() { - val manga = savedStateHandle.require(MangaIntent.KEY_MANGA).manga + val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga + + private var includeDisabledSources = MutableStateFlow(false) + private val results = MutableStateFlow>(emptyList()) - val onMigrated = MutableEventFlow() - val content = MutableStateFlow>(listOf(LoadingState)) private var migrationJob: Job? = null + private var searchJob: Job? = null - init { - launchJob(Dispatchers.Default) { - val ref = runCatchingCancellable { - mangaRepositoryFactory.create(manga.source).getDetails(manga) - }.getOrDefault(manga) - val refCount = ref.chaptersCount() - alternativesUseCase(ref) - .map { - MangaAlternativeModel( - manga = it, - progress = getProgress(it.id), - referenceChapters = refCount, - ) - }.runningFold>(listOf(LoadingState)) { acc, item -> - acc.filterIsInstance() + item + LoadingFooter() - }.onEmpty { - emit( - listOf( - EmptyState( - icon = R.drawable.ic_empty_common, - textPrimary = R.string.nothing_found, - textSecondary = R.string.text_search_holder_secondary, - actionStringRes = 0, - ), - ), + private val mangaDetails = suspendLazy { + mangaRepositoryFactory.create(manga.source).getDetails(manga) + } + + val onMigrated = MutableEventFlow() + + val list: StateFlow> = combine( + results, + isLoading, + includeDisabledSources, + ) { list, loading, includeDisabled -> + when { + list.isEmpty() -> listOf( + when { + loading -> LoadingState + else -> EmptyState( + icon = R.drawable.ic_empty_common, + textPrimary = R.string.nothing_found, + textSecondary = R.string.text_search_holder_secondary, + actionStringRes = 0, ) - }.collect { - content.value = it - } - content.value = content.value.filterNot { it is LoadingFooter } + }, + ) + + loading -> list + LoadingFooter() + includeDisabled -> list + else -> list + ButtonFooter(R.string.search_disabled_sources) + } + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) + + init { + doSearch(throughDisabledSources = false) + } + + fun retry() { + searchJob?.cancel() + results.value = emptyList() + includeDisabledSources.value = false + doSearch(throughDisabledSources = false) + } + + fun continueSearch() { + if (includeDisabledSources.value) { + return + } + val prevJob = searchJob + searchJob = launchLoadingJob(Dispatchers.Default) { + includeDisabledSources.value = true + prevJob?.join() + doSearch(throughDisabledSources = true) } } @@ -89,7 +117,20 @@ class AlternativesViewModel @Inject constructor( } } - private suspend fun getProgress(mangaId: Long): ReadingProgress? { - return historyRepository.getProgress(mangaId, settings.progressIndicatorMode) + private fun doSearch(throughDisabledSources: Boolean) { + val prevJob = searchJob + searchJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + val ref = mangaDetails.getOrDefault(manga) + val refCount = ref.chaptersCount() + alternativesUseCase.invoke(ref, throughDisabledSources) + .collect { + val model = MangaAlternativeModel( + mangaModel = mangaListMapper.toListModel(it, ListMode.GRID) as MangaGridModel, + referenceChapters = refCount, + ) + results.append(model) + } + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt index 3e7f8b49e..84ba7717b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/AutoFixService.kt @@ -10,7 +10,6 @@ import androidx.core.app.NotificationChannelCompat import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.app.PendingIntentCompat -import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import coil3.ImageLoader import coil3.request.ImageRequest @@ -20,13 +19,15 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.alternatives.domain.AutoFixUseCase import org.koitharu.kotatsu.core.ErrorReporterReceiver import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra +import org.koitharu.kotatsu.core.util.ext.powerManager import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull -import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import javax.inject.Inject @@ -52,12 +53,14 @@ class AutoFixService : CoroutineIntentService() { val ids = requireNotNull(intent.getLongArrayExtra(DATA_IDS)) startForeground(this) for (mangaId in ids) { - val result = runCatchingCancellable { - autoFixUseCase.invoke(mangaId) - } - if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { - val notification = buildNotification(result) - notificationManager.notify(TAG, startId, notification) + powerManager.withPartialWakeLock(TAG) { + val result = runCatchingCancellable { + autoFixUseCase.invoke(mangaId) + } + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val notification = buildNotification(result) + notificationManager.notify(TAG, startId, notification) + } } } } @@ -122,7 +125,7 @@ class AutoFixService : CoroutineIntentService() { ).toBitmapOrNull(), ) notification.setSubText(replacement.title) - val intent = DetailsActivity.newIntent(applicationContext, replacement) + val intent = AppRouter.detailsIntent(applicationContext, replacement) notification.setContentIntent( PendingIntentCompat.getActivity( applicationContext, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/MangaAlternativeModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/MangaAlternativeModel.kt index da9d9b5a6..48d3146f8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/MangaAlternativeModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/alternatives/ui/MangaAlternativeModel.kt @@ -1,16 +1,18 @@ package org.koitharu.kotatsu.alternatives.ui import org.koitharu.kotatsu.core.model.chaptersCount -import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.ui.model.ListModel +import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.parsers.model.Manga data class MangaAlternativeModel( - val manga: Manga, - val progress: ReadingProgress?, + val mangaModel: MangaGridModel, private val referenceChapters: Int, ) : ListModel { + val manga: Manga + get() = mangaModel.manga + val chaptersCount = manga.chaptersCount() val chaptersDiff: Int @@ -19,4 +21,10 @@ data class MangaAlternativeModel( override fun areItemsTheSame(other: ListModel): Boolean { return other is MangaAlternativeModel && other.manga.id == manga.id } + + override fun getChangePayload(previousState: ListModel): Any? = if (previousState is MangaAlternativeModel) { + mangaModel.getChangePayload(previousState.mangaModel) + } else { + null + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksActivity.kt index 85591a372..7a3ccc818 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksActivity.kt @@ -1,54 +1,5 @@ package org.koitharu.kotatsu.bookmarks.ui -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.coordinatorlayout.widget.CoordinatorLayout -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner +import org.koitharu.kotatsu.core.ui.FragmentContainerActivity -@AndroidEntryPoint -class AllBookmarksActivity : - BaseActivity(), - AppBarOwner, - SnackbarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override val snackbarHost: CoordinatorLayout - get() = viewBinding.root - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - replace(R.id.container, AllBookmarksFragment::class.java, null) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::class.java) - } -} +class AllBookmarksActivity : FragmentContainerActivity(AllBookmarksFragment::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt index 5670eb82a..e76c760a4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksFragment.kt @@ -9,9 +9,7 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import coil3.ImageLoader @@ -20,17 +18,20 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.ReaderIntent +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.FragmentListSimpleBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.adapter.ListHeaderClickListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType @@ -39,7 +40,6 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.reader.ui.ReaderActivity import javax.inject.Inject @AndroidEntryPoint @@ -107,6 +107,18 @@ class AllBookmarksFragment : viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + val basePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) + viewBinding?.recyclerView?.setPadding( + barsInsets.left + basePadding, + barsInsets.top + basePadding, + barsInsets.right + basePadding, + barsInsets.bottom + basePadding, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onDestroyView() { super.onDestroyView() bookmarksAdapter = null @@ -115,26 +127,26 @@ class AllBookmarksFragment : override fun onItemClick(item: Bookmark, view: View) { if (selectionController?.onItemClick(item.pageId) != true) { - val intent = ReaderActivity.IntentBuilder(view.context) + val intent = ReaderIntent.Builder(view.context) .bookmark(item) .incognito(true) .build() - startActivity(intent) + router.openReader(intent) Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() } } override fun onListHeaderClick(item: ListHeader, view: View) { val manga = item.payload as? Manga ?: return - startActivity(DetailsActivity.newIntent(view.context, manga)) + router.openDetails(manga) } override fun onItemLongClick(item: Bookmark, view: View): Boolean { - return selectionController?.onItemLongClick(view, item.pageId) ?: false + return selectionController?.onItemLongClick(view, item.pageId) == true } override fun onItemContextClick(item: Bookmark, view: View): Boolean { - return selectionController?.onItemContextClick(view, item.pageId) ?: false + return selectionController?.onItemContextClick(view, item.pageId) == true } override fun onRetryClick(error: Throwable) = Unit @@ -177,16 +189,6 @@ class AllBookmarksFragment : } } - override fun onWindowInsetsChanged(insets: Insets) { - val rv = requireViewBinding().recyclerView - rv.updatePadding( - bottom = insets.bottom + rv.paddingTop, - ) - rv.fastScroller.updateLayoutParams { - bottomMargin = insets.bottom - } - } - private inner class SpanSizeLookup : GridLayoutManager.SpanSizeLookup(), Runnable { init { @@ -208,16 +210,4 @@ class AllBookmarksFragment : invalidateSpanIndexCache() } } - - companion object { - - @Deprecated( - "", - ReplaceWith( - "BookmarksFragment()", - "org.koitharu.kotatsu.bookmarks.ui.BookmarksFragment", - ), - ) - fun newInstance() = AllBookmarksFragment() - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt deleted file mode 100644 index d92f8a2da..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/adapter/BookmarkListAD.kt +++ /dev/null @@ -1,38 +0,0 @@ -package org.koitharu.kotatsu.bookmarks.ui.adapter - -import androidx.lifecycle.LifecycleOwner -import coil3.ImageLoader -import coil3.request.allowRgb565 -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.bookmarks.domain.Bookmark -import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver -import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.bookmarkExtra -import org.koitharu.kotatsu.core.util.ext.decodeRegion -import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.databinding.ItemBookmarkBinding - -// TODO check usages -fun bookmarkListAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, - clickListener: OnListItemClickListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemBookmarkBinding.inflate(inflater, parent, false) }, -) { - AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) - - bind { - binding.imageViewThumb.newImageRequest(lifecycleOwner, item.imageLoadData)?.run { - size(CoverSizeResolver(binding.imageViewThumb)) - defaultPlaceholders(context) - allowRgb565(true) - bookmarkExtra(item) - decodeRegion(item.scroll) - enqueueWith(coil) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BaseBrowserActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BaseBrowserActivity.kt new file mode 100644 index 000000000..2f1359b43 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BaseBrowserActivity.kt @@ -0,0 +1,82 @@ +package org.koitharu.kotatsu.browser + +import android.os.Bundle +import android.view.View +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.core.network.proxy.ProxyProvider +import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.ext.consumeAll +import org.koitharu.kotatsu.databinding.ActivityBrowserBinding +import javax.inject.Inject + +@AndroidEntryPoint +abstract class BaseBrowserActivity : BaseActivity(), BrowserCallback { + + @Inject + lateinit var proxyProvider: ProxyProvider + + private lateinit var onBackPressedCallback: WebViewBackPressedCallback + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { + return + } + viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) + onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) + onBackPressedDispatcher.addCallback(onBackPressedCallback) + } + + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + val type = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() + val barsInsets = insets.getInsets(type) + viewBinding.webView.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + viewBinding.appbar.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + top = barsInsets.top, + ) + return insets.consumeAll(type) + } + + override fun onPause() { + viewBinding.webView.onPause() + super.onPause() + } + + override fun onResume() { + super.onResume() + viewBinding.webView.onResume() + } + + override fun onDestroy() { + super.onDestroy() + if (hasViewBinding()) { + viewBinding.webView.stopLoading() + viewBinding.webView.destroy() + } + } + + override fun onLoadingStateChanged(isLoading: Boolean) { + viewBinding.progressBar.isVisible = isLoading + } + + override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { + this.title = title + supportActionBar?.subtitle = subtitle + } + + override fun onHistoryChanged() { + onBackPressedCallback.onHistoryChanged() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt index 159550134..5961f0046 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt @@ -1,68 +1,57 @@ package org.koitharu.kotatsu.browser -import android.content.ActivityNotFoundException -import android.content.Context -import android.content.Intent -import android.net.Uri import android.os.Bundle import android.view.Menu import android.view.MenuItem -import android.webkit.CookieManager -import androidx.core.graphics.Insets -import androidx.core.view.isVisible -import androidx.core.view.updatePadding +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository -import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.configureForParser -import org.koitharu.kotatsu.core.util.ext.toUriOrNull -import org.koitharu.kotatsu.databinding.ActivityBrowserBinding -import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import javax.inject.Inject -import com.google.android.material.R as materialR @AndroidEntryPoint -class BrowserActivity : BaseActivity(), BrowserCallback { - - private lateinit var onBackPressedCallback: WebViewBackPressedCallback +class BrowserActivity : BaseBrowserActivity() { @Inject lateinit var mangaRepositoryFactory: MangaRepository.Factory override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { - return - } - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } - val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE)) + setDisplayHomeAsUp(true, true) + val mangaSource = MangaSource(intent?.getStringExtra(AppRouter.KEY_SOURCE)) val repository = mangaRepositoryFactory.create(mangaSource) as? ParserMangaRepository val userAgent = repository?.getRequestHeaders()?.get(CommonHeaders.USER_AGENT) viewBinding.webView.configureForParser(userAgent) - CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) - viewBinding.webView.webViewClient = BrowserClient(this) - viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) - onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) - onBackPressedDispatcher.addCallback(onBackPressedCallback) - if (savedInstanceState != null) { - return - } - val url = intent?.dataString - if (url.isNullOrEmpty()) { - finishAfterTransition() - } else { - onTitleChanged( - intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_), - url, - ) - viewBinding.webView.loadUrl(url) + viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this) + lifecycleScope.launch { + try { + proxyProvider.applyWebViewConfig() + } catch (e: Exception) { + e.printStackTraceDebug() + Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() + } + if (savedInstanceState == null) { + val url = intent?.dataString + if (url.isNullOrEmpty()) { + finishAfterTransition() + } else { + onTitleChanged( + intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_), + url, + ) + viewBinding.webView.loadUrl(url) + } + } } } @@ -80,73 +69,12 @@ class BrowserActivity : BaseActivity(), BrowserCallback } R.id.action_browser -> { - val url = viewBinding.webView.url?.toUriOrNull() - if (url != null) { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = url - try { - startActivity(Intent.createChooser(intent, item.title)) - } catch (_: ActivityNotFoundException) { - } + if (!router.openExternalBrowser(viewBinding.webView.url.orEmpty(), item.title)) { + Snackbar.make(viewBinding.webView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } true } else -> super.onOptionsItemSelected(item) } - - override fun onPause() { - viewBinding.webView.onPause() - super.onPause() - } - - override fun onResume() { - super.onResume() - viewBinding.webView.onResume() - } - - override fun onDestroy() { - super.onDestroy() - if (hasViewBinding()) { - viewBinding.webView.stopLoading() - viewBinding.webView.destroy() - } - } - - override fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.progressBar.isVisible = isLoading - } - - override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { - this.title = title - supportActionBar?.subtitle = subtitle - } - - override fun onHistoryChanged() { - onBackPressedCallback.onHistoryChanged() - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.appbar.updatePadding( - top = insets.top, - ) - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - bottom = insets.bottom, - ) - } - - companion object { - - private const val EXTRA_TITLE = "title" - private const val EXTRA_SOURCE = "source" - - fun newIntent(context: Context, url: String, source: MangaSource?, title: String?): Intent { - return Intent(context, BrowserActivity::class.java) - .setData(Uri.parse(url)) - .putExtra(EXTRA_TITLE, title) - .putExtra(EXTRA_SOURCE, source?.name) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt index e6906014e..9d72bcfe7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserClient.kt @@ -2,9 +2,13 @@ package org.koitharu.kotatsu.browser import android.graphics.Bitmap import android.webkit.WebView -import android.webkit.WebViewClient +import androidx.webkit.WebViewClientCompat +import org.koitharu.kotatsu.core.network.proxy.ProxyProvider -open class BrowserClient(private val callback: BrowserCallback) : WebViewClient() { +open class BrowserClient( + private val proxyProvider: ProxyProvider, + private val callback: BrowserCallback +) : WebViewClientCompat() { override fun onPageFinished(webView: WebView, url: String) { super.onPageFinished(webView, url) @@ -16,7 +20,7 @@ open class BrowserClient(private val callback: BrowserCallback) : WebViewClient( callback.onLoadingStateChanged(isLoading = true) } - override fun onPageCommitVisible(view: WebView, url: String?) { + override fun onPageCommitVisible(view: WebView, url: String) { super.onPageCommitVisible(view, url) callback.onTitleChanged(view.title.orEmpty(), url) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt index 48d900c10..41ea4dc6c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt @@ -17,6 +17,8 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.parsers.model.MangaSource @@ -28,6 +30,9 @@ class CaptchaNotifier( if (!context.checkNotificationPermission(CHANNEL_ID)) { return } + if (exception.source != null && SourceSettings(context, exception.source).isCaptchaNotificationsDisabled) { + return + } val manager = NotificationManagerCompat.from(context) val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) .setName(context.getString(R.string.captcha_required)) @@ -38,7 +43,7 @@ class CaptchaNotifier( .build() manager.createNotificationChannel(channel) - val intent = CloudFlareActivity.newIntent(context, exception) + val intent = AppRouter.cloudFlareResolveIntent(context, exception) .setData(exception.url.toUri()) val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setContentTitle(channel.name) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt index 09738c113..b21479a2d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareActivity.kt @@ -1,43 +1,34 @@ package org.koitharu.kotatsu.browser.cloudflare -import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuItem -import android.webkit.CookieManager import androidx.activity.result.contract.ActivityResultContract -import androidx.core.graphics.Insets -import androidx.core.net.toUri import androidx.core.view.isInvisible -import androidx.core.view.isVisible -import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.yield -import okhttp3.Headers import okhttp3.HttpUrl import okhttp3.HttpUrl.Companion.toHttpUrlOrNull import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.WebViewBackPressedCallback +import org.koitharu.kotatsu.browser.BaseBrowserActivity import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar -import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.configureForParser -import org.koitharu.kotatsu.databinding.ActivityBrowserBinding -import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.parsers.network.CloudFlareHelper import javax.inject.Inject -import com.google.android.material.R as materialR @AndroidEntryPoint -class CloudFlareActivity : BaseActivity(), CloudFlareCallback { +class CloudFlareActivity : BaseBrowserActivity(), CloudFlareCallback { private var pendingResult = RESULT_CANCELED @@ -45,43 +36,29 @@ class CloudFlareActivity : BaseActivity(), CloudFlareCal lateinit var cookieJar: MutableCookieJar private lateinit var cfClient: CloudFlareClient - private var onBackPressedCallback: WebViewBackPressedCallback? = null override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { - return - } - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } + setDisplayHomeAsUp(true, true) val url = intent?.dataString if (url.isNullOrEmpty()) { finishAfterTransition() return } - cfClient = CloudFlareClient(cookieJar, this, url) - viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA)) + cfClient = CloudFlareClient(proxyProvider, cookieJar, this, url) + viewBinding.webView.configureForParser(intent?.getStringExtra(AppRouter.KEY_USER_AGENT)) viewBinding.webView.webViewClient = cfClient - onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView).also { - onBackPressedDispatcher.addCallback(it) - } - CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) - if (savedInstanceState == null) { - onTitleChanged(getString(R.string.loading_), url) - viewBinding.webView.loadUrl(url) - } - } - - override fun onDestroy() { - runCatching { - viewBinding.webView - }.onSuccess { - it.stopLoading() - it.destroy() + lifecycleScope.launch { + try { + proxyProvider.applyWebViewConfig() + } catch (e: Exception) { + Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() + } + if (savedInstanceState == null) { + onTitleChanged(getString(R.string.loading_), url) + viewBinding.webView.loadUrl(url) + } } - super.onDestroy() } override fun onCreateOptionsMenu(menu: Menu?): Boolean { @@ -89,17 +66,6 @@ class CloudFlareActivity : BaseActivity(), CloudFlareCal return super.onCreateOptionsMenu(menu) } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.appbar.updatePadding( - top = insets.top, - ) - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - bottom = insets.bottom, - ) - } - override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { viewBinding.webView.stopLoading() @@ -115,21 +81,13 @@ class CloudFlareActivity : BaseActivity(), CloudFlareCal else -> super.onOptionsItemSelected(item) } - override fun onResume() { - super.onResume() - viewBinding.webView.onResume() - } - - override fun onPause() { - viewBinding.webView.onPause() - super.onPause() - } - override fun finish() { setResult(pendingResult) super.finish() } + override fun onLoadingStateChanged(isLoading: Boolean) = Unit + override fun onPageLoaded() { viewBinding.progressBar.isInvisible = true } @@ -140,21 +98,13 @@ class CloudFlareActivity : BaseActivity(), CloudFlareCal override fun onCheckPassed() { pendingResult = RESULT_OK - val source = intent?.getStringExtra(ARG_SOURCE) + val source = intent?.getStringExtra(AppRouter.KEY_SOURCE) if (source != null) { CaptchaNotifier(this).dismiss(MangaSource(source)) } finishAfterTransition() } - override fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.progressBar.isVisible = isLoading - } - - override fun onHistoryChanged() { - onBackPressedCallback?.onHistoryChanged() - } - override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { setTitle(title) supportActionBar?.subtitle = @@ -182,38 +132,16 @@ class CloudFlareActivity : BaseActivity(), CloudFlareCal class Contract : ActivityResultContract() { override fun createIntent(context: Context, input: CloudFlareProtectedException): Intent { - return newIntent(context, input) + return AppRouter.cloudFlareResolveIntent(context, input) } override fun parseResult(resultCode: Int, intent: Intent?): Boolean { - return resultCode == Activity.RESULT_OK + return resultCode == RESULT_OK } } companion object { const val TAG = "CloudFlareActivity" - private const val ARG_UA = "ua" - private const val ARG_SOURCE = "_source" - - fun newIntent(context: Context, exception: CloudFlareProtectedException) = newIntent( - context = context, - url = exception.url, - source = exception.source, - headers = exception.headers, - ) - - private fun newIntent( - context: Context, - url: String, - source: MangaSource?, - headers: Headers?, - ) = Intent(context, CloudFlareActivity::class.java).apply { - data = url.toUri() - putExtra(ARG_SOURCE, source?.name) - headers?.get(CommonHeaders.USER_AGENT)?.let { - putExtra(ARG_UA, it) - } - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt index b38527c6a..83e86d900 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareCallback.kt @@ -4,8 +4,6 @@ import org.koitharu.kotatsu.browser.BrowserCallback interface CloudFlareCallback : BrowserCallback { - override fun onLoadingStateChanged(isLoading: Boolean) = Unit - override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) = Unit fun onPageLoaded() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt index e5bd0c5cb..18c353c03 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CloudFlareClient.kt @@ -4,15 +4,17 @@ import android.graphics.Bitmap import android.webkit.WebView import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.core.network.proxy.ProxyProvider import org.koitharu.kotatsu.parsers.network.CloudFlareHelper private const val LOOP_COUNTER = 3 class CloudFlareClient( + proxyProvider: ProxyProvider, private val cookieJar: MutableCookieJar, private val callback: CloudFlareCallback, private val targetUrl: String, -) : BrowserClient(callback) { +) : BrowserClient(proxyProvider, callback) { private val oldClearance = getClearance() private var counter = 0 @@ -22,7 +24,7 @@ class CloudFlareClient( checkClearance() } - override fun onPageCommitVisible(view: WebView, url: String?) { + override fun onPageCommitVisible(view: WebView, url: String) { super.onPageCommitVisible(view, url) callback.onPageLoaded() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt index 57ef8da27..d9e402e05 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/AppModule.kt @@ -6,6 +6,7 @@ import android.os.Build import android.provider.SearchRecentSuggestions import android.text.Html import androidx.collection.arraySetOf +import androidx.core.content.ContextCompat import androidx.room.InvalidationTracker import androidx.work.WorkManager import coil3.ImageLoader @@ -76,6 +77,12 @@ interface AppModule { companion object { + @Provides + @LocalizedAppContext + fun provideLocalizedContext( + @ApplicationContext context: Context, + ): Context = ContextCompat.getContextForLanguage(context) + @Provides @Singleton fun provideNetworkState( @@ -92,7 +99,7 @@ interface AppModule { @Provides @Singleton fun provideCoil( - @ApplicationContext context: Context, + @LocalizedAppContext context: Context, @MangaHttpClient okHttpClientProvider: Provider, mangaRepositoryFactory: MangaRepository.Factory, imageProxyInterceptor: ImageProxyInterceptor, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt index 8825d4cbe..1ee78bd06 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt @@ -13,7 +13,6 @@ import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.acra.ACRA import org.acra.ReportField import org.acra.config.dialog @@ -26,12 +25,14 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.os.AppValidator +import org.koitharu.kotatsu.core.os.RomCompat import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.WorkServiceStopHelper import org.koitharu.kotatsu.core.util.ext.processLifecycleScope import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.domain.model.LocalManga +import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull import org.koitharu.kotatsu.settings.work.WorkScheduleManager import java.security.Security import javax.inject.Inject @@ -82,17 +83,14 @@ open class BaseApp : Application(), Configuration.Provider { return } AppCompatDelegate.setDefaultNightMode(settings.theme) - AppCompatDelegate.setApplicationLocales(settings.appLocales) // TLS 1.3 support for Android < 10 if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { Security.insertProviderAt(Conscrypt.newProvider(), 1) } setupActivityLifecycleCallbacks() processLifecycleScope.launch { - val isOriginalApp = withContext(Dispatchers.Default) { - appValidator.isOriginalApp - } - ACRA.errorReporter.putCustomData("isOriginalApp", isOriginalApp.toString()) + ACRA.errorReporter.putCustomData("isOriginalApp", appValidator.isOriginalApp.getOrNull().toString()) + ACRA.errorReporter.putCustomData("isMiui", RomCompat.isMiui.getOrNull().toString()) } processLifecycleScope.launch(Dispatchers.Default) { setupDatabaseObservers() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt index 628093428..27e42a2a5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ErrorReporterReceiver.kt @@ -8,6 +8,7 @@ import android.net.Uri import android.os.BadParcelableException import androidx.core.app.PendingIntentCompat import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.report @@ -15,20 +16,19 @@ import org.koitharu.kotatsu.core.util.ext.report class ErrorReporterReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - val e = intent?.getSerializableExtraCompat(EXTRA_ERROR) ?: return + val e = intent?.getSerializableExtraCompat(AppRouter.KEY_ERROR) ?: return e.report() } companion object { - private const val EXTRA_ERROR = "err" private const val ACTION_REPORT = "${BuildConfig.APPLICATION_ID}.action.REPORT_ERROR" fun getPendingIntent(context: Context, e: Throwable): PendingIntent? = try { val intent = Intent(context, ErrorReporterReceiver::class.java) intent.setAction(ACTION_REPORT) intent.setData(Uri.parse("err://${e.hashCode()}")) - intent.putExtra(EXTRA_ERROR, e) + intent.putExtra(AppRouter.KEY_ERROR, e) PendingIntentCompat.getBroadcast(context, 0, intent, 0, false) } catch (e: BadParcelableException) { e.printStackTraceDebug() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/LocalizedAppContext.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/LocalizedAppContext.kt new file mode 100644 index 000000000..59777a36f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/LocalizedAppContext.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.core + +import javax.inject.Qualifier + +@Qualifier +@Target( + AnnotationTarget.FUNCTION, + AnnotationTarget.PROPERTY_GETTER, + AnnotationTarget.PROPERTY_SETTER, + AnnotationTarget.VALUE_PARAMETER, + AnnotationTarget.FIELD, +) +annotation class LocalizedAppContext diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt index ae92bff4e..9dd16bc43 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupEntry.kt @@ -16,6 +16,7 @@ class BackupEntry( CATEGORIES("categories"), FAVOURITES("favourites"), SETTINGS("settings"), + SETTINGS_READER_GRID("reader_grid"), BOOKMARKS("bookmarks"), SOURCES("sources"), } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt index a8c45f0ca..5a63fd5aa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt @@ -1,15 +1,18 @@ package org.koitharu.kotatsu.core.backup import androidx.room.withTransaction +import kotlinx.coroutines.flow.FlowCollector import org.json.JSONArray import org.json.JSONObject import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.progress.Progress import org.koitharu.kotatsu.parsers.util.json.asTypedList import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.mapJSON import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.reader.data.TapGridSettings import java.util.Date import javax.inject.Inject @@ -18,6 +21,7 @@ private const val PAGE_SIZE = 10 class BackupRepository @Inject constructor( private val db: MangaDatabase, private val settings: AppSettings, + private val tapGridSettings: TapGridSettings, ) { suspend fun dumpHistory(): BackupEntry { @@ -103,6 +107,14 @@ class BackupRepository @Inject constructor( return entry } + fun dumpReaderGridSettings(): BackupEntry { + val entry = BackupEntry(BackupEntry.Name.SETTINGS_READER_GRID, JSONArray()) + val settingsDump = tapGridSettings.getAllValues() + val json = JsonSerializer(settingsDump).toJson() + entry.data.put(json) + return entry + } + suspend fun dumpSources(): BackupEntry { val entry = BackupEntry(BackupEntry.Name.SOURCES, JSONArray()) val all = db.getSourcesDao().findAll() @@ -128,9 +140,11 @@ class BackupRepository @Inject constructor( return if (timestamp == 0L) null else Date(timestamp) } - suspend fun restoreHistory(entry: BackupEntry): CompositeResult { + suspend fun restoreHistory(entry: BackupEntry, outProgress: FlowCollector?): CompositeResult { val result = CompositeResult() - for (item in entry.data.asTypedList()) { + val list = entry.data.asTypedList() + outProgress?.emit(Progress(progress = 0, total = list.size)) + for ((index, item) in list.withIndex()) { val mangaJson = item.getJSONObject("manga") val manga = JsonDeserializer(mangaJson).toMangaEntity() val tags = mangaJson.getJSONArray("tags").mapJSON { @@ -144,6 +158,7 @@ class BackupRepository @Inject constructor( db.getHistoryDao().upsert(history) } } + outProgress?.emit(Progress(progress = index, total = list.size)) } return result } @@ -159,9 +174,11 @@ class BackupRepository @Inject constructor( return result } - suspend fun restoreFavourites(entry: BackupEntry): CompositeResult { + suspend fun restoreFavourites(entry: BackupEntry, outProgress: FlowCollector?): CompositeResult { val result = CompositeResult() - for (item in entry.data.asTypedList()) { + val list = entry.data.asTypedList() + outProgress?.emit(Progress(progress = 0, total = list.size)) + for ((index, item) in list.withIndex()) { val mangaJson = item.getJSONObject("manga") val manga = JsonDeserializer(mangaJson).toMangaEntity() val tags = mangaJson.getJSONArray("tags").mapJSON { @@ -175,6 +192,7 @@ class BackupRepository @Inject constructor( db.getFavouritesDao().upsert(favourite) } } + outProgress?.emit(Progress(progress = index, total = list.size)) } return result } @@ -221,4 +239,14 @@ class BackupRepository @Inject constructor( } return result } + + fun restoreReaderGridSettings(entry: BackupEntry): CompositeResult { + val result = CompositeResult() + for (item in entry.data.asTypedList()) { + result += runCatchingCancellable { + tapGridSettings.upsertAll(JsonDeserializer(item).toMap()) + } + } + return result + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt index 9311bb253..4f1ba1ca2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/CompositeResult.kt @@ -27,6 +27,10 @@ class CompositeResult { } } + operator fun plusAssign(error: Throwable) { + errors.add(error) + } + operator fun plusAssign(other: CompositeResult) { this.successCount += other.successCount this.errors += other.errors diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt index cc042d7fe..8ecc60e98 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonDeserializer.kt @@ -28,15 +28,16 @@ class JsonDeserializer(private val json: JSONObject) { fun toMangaEntity() = MangaEntity( id = json.getLong("id"), title = json.getString("title"), - altTitle = json.getStringOrNull("alt_title"), + altTitles = json.getStringOrNull("alt_title"), url = json.getString("url"), publicUrl = json.getStringOrNull("public_url").orEmpty(), rating = json.getDouble("rating").toFloat(), isNsfw = json.getBooleanOrDefault("nsfw", false), + contentRating = json.getStringOrNull("content_rating"), coverUrl = json.getString("cover_url"), largeCoverUrl = json.getStringOrNull("large_cover_url"), state = json.getStringOrNull("state"), - author = json.getStringOrNull("author"), + authors = json.getStringOrNull("author"), source = json.getString("source"), ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt index 28bf270da..16c3af122 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializer.kt @@ -58,15 +58,16 @@ class JsonSerializer private constructor(private val json: JSONObject) { JSONObject().apply { put("id", e.id) put("title", e.title) - put("alt_title", e.altTitle) + put("alt_title", e.altTitles) put("url", e.url) put("public_url", e.publicUrl) put("rating", e.rating) put("nsfw", e.isNsfw) + put("content_rating", e.contentRating) put("cover_url", e.coverUrl) put("large_cover_url", e.largeCoverUrl) put("state", e.state) - put("author", e.author) + put("author", e.authors) put("source", e.source) }, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt new file mode 100644 index 000000000..6426733c2 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/TelegramBackupUploader.kt @@ -0,0 +1,93 @@ +package org.koitharu.kotatsu.core.backup + +import android.content.Context +import androidx.annotation.CheckResult +import dagger.hilt.android.qualifiers.ApplicationContext +import okhttp3.HttpUrl +import okhttp3.MediaType.Companion.toMediaTypeOrNull +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.Response +import okhttp3.internal.closeQuietly +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.network.BaseHttpClient +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.parsers.util.await +import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault +import org.koitharu.kotatsu.parsers.util.json.getStringOrNull +import org.koitharu.kotatsu.parsers.util.parseJson +import java.io.File +import javax.inject.Inject + +class TelegramBackupUploader @Inject constructor( + private val settings: AppSettings, + @BaseHttpClient private val client: OkHttpClient, + @ApplicationContext private val context: Context, +) { + + private val botToken = context.getString(R.string.tg_backup_bot_token) + + suspend fun uploadBackup(file: File) { + val requestBody = file.asRequestBody("application/zip".toMediaTypeOrNull()) + val multipartBody = MultipartBody.Builder() + .setType(MultipartBody.FORM) + .addFormDataPart("chat_id", requireChatId()) + .addFormDataPart("document", file.name, requestBody) + .build() + val request = Request.Builder() + .url(urlOf("sendDocument").build()) + .post(multipartBody) + .build() + client.newCall(request).await().consume() + } + + suspend fun sendTestMessage() { + val request = Request.Builder() + .url(urlOf("getMe").build()) + .build() + client.newCall(request).await().consume() + sendMessage(context.getString(R.string.backup_tg_echo)) + } + + @CheckResult + fun openBotInApp(router: AppRouter): Boolean { + val botUsername = context.getString(R.string.tg_backup_bot_name) + return router.openExternalBrowser("tg://resolve?domain=$botUsername") || + router.openExternalBrowser("https://t.me/$botUsername") + } + + private suspend fun sendMessage(message: String) { + val url = urlOf("sendMessage") + .addQueryParameter("chat_id", requireChatId()) + .addQueryParameter("text", message) + .build() + val request = Request.Builder() + .url(url) + .build() + client.newCall(request).await().consume() + } + + private fun requireChatId() = checkNotNull(settings.backupTelegramChatId) { + "Telegram chat ID not set in settings" + } + + private fun Response.consume() { + if (isSuccessful) { + closeQuietly() + return + } + val jo = parseJson() + if (!jo.getBooleanOrDefault("ok", true)) { + throw RuntimeException(jo.getStringOrNull("description")) + } + } + + private fun urlOf(method: String) = HttpUrl.Builder() + .scheme("https") + .host("api.telegram.org") + .addPathSegment("bot$botToken") + .addPathSegment(method) +} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt index 750682a84..2a54236dc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/ExpiringLruCache.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.cache -import androidx.collection.LruCache +import org.koitharu.kotatsu.core.util.SynchronizedSieveCache +import org.koitharu.kotatsu.parsers.model.MangaSource import java.util.concurrent.TimeUnit import org.koitharu.kotatsu.core.cache.MemoryContentCache.Key as CacheKey @@ -8,11 +9,9 @@ class ExpiringLruCache( val maxSize: Int, private val lifetime: Long, private val timeUnit: TimeUnit, -) : Iterable { +) { - private val cache = LruCache>(maxSize) - - override fun iterator(): Iterator = cache.snapshot().keys.iterator() + private val cache = SynchronizedSieveCache>(maxSize) operator fun get(key: CacheKey): T? { val value = cache[key] ?: return null @@ -23,7 +22,8 @@ class ExpiringLruCache( } operator fun set(key: CacheKey, value: T) { - cache.put(key, ExpiringValue(value, lifetime, timeUnit)) + val value = ExpiringValue(value, lifetime, timeUnit) + cache.put(key, value) } fun clear() { @@ -37,4 +37,8 @@ class ExpiringLruCache( fun remove(key: CacheKey) { cache.remove(key) } + + fun removeAll(source: MangaSource) { + cache.removeIf { key, _ -> key.source == source } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt index c76d7f317..cf78bcb6b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/cache/MemoryContentCache.kt @@ -81,11 +81,7 @@ class MemoryContentCache @Inject constructor(application: Application) : Compone } private fun clearCache(cache: ExpiringLruCache<*>, source: MangaSource) { - cache.forEach { key -> - if (key.source == source) { - cache.remove(key) - } - } + cache.removeAll(source) } data class Key( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt index f87bc5b0a..52579ee8f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt @@ -12,11 +12,13 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import org.koitharu.kotatsu.bookmarks.data.BookmarkEntity import org.koitharu.kotatsu.bookmarks.data.BookmarksDao +import org.koitharu.kotatsu.core.db.dao.ChaptersDao import org.koitharu.kotatsu.core.db.dao.MangaDao import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao import org.koitharu.kotatsu.core.db.dao.PreferencesDao import org.koitharu.kotatsu.core.db.dao.TagsDao import org.koitharu.kotatsu.core.db.dao.TrackLogsDao +import org.koitharu.kotatsu.core.db.entity.ChapterEntity import org.koitharu.kotatsu.core.db.entity.MangaEntity import org.koitharu.kotatsu.core.db.entity.MangaPrefsEntity import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity @@ -36,7 +38,9 @@ import org.koitharu.kotatsu.core.db.migrations.Migration1To2 import org.koitharu.kotatsu.core.db.migrations.Migration20To21 import org.koitharu.kotatsu.core.db.migrations.Migration21To22 import org.koitharu.kotatsu.core.db.migrations.Migration22To23 +import org.koitharu.kotatsu.core.db.migrations.Migration23To24 import org.koitharu.kotatsu.core.db.migrations.Migration24To23 +import org.koitharu.kotatsu.core.db.migrations.Migration24To25 import org.koitharu.kotatsu.core.db.migrations.Migration2To3 import org.koitharu.kotatsu.core.db.migrations.Migration3To4 import org.koitharu.kotatsu.core.db.migrations.Migration4To5 @@ -64,14 +68,14 @@ import org.koitharu.kotatsu.tracker.data.TrackEntity import org.koitharu.kotatsu.tracker.data.TrackLogEntity import org.koitharu.kotatsu.tracker.data.TracksDao -const val DATABASE_VERSION = 23 +const val DATABASE_VERSION = 25 @Database( entities = [ - MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, - FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, - TrackEntity::class, TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, - ScrobblingEntity::class, MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class, + MangaEntity::class, TagEntity::class, HistoryEntity::class, MangaTagsEntity::class, ChapterEntity::class, + FavouriteCategoryEntity::class, FavouriteEntity::class, MangaPrefsEntity::class, TrackEntity::class, + TrackLogEntity::class, SuggestionEntity::class, BookmarkEntity::class, ScrobblingEntity::class, + MangaSourceEntity::class, StatsEntity::class, LocalMangaIndexEntity::class, ], version = DATABASE_VERSION, ) @@ -104,6 +108,8 @@ abstract class MangaDatabase : RoomDatabase() { abstract fun getStatsDao(): StatsDao abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao + + abstract fun getChaptersDao(): ChaptersDao } fun getDatabaseMigrations(context: Context): Array = arrayOf( @@ -129,7 +135,9 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf( Migration20To21(), Migration21To22(), Migration22To23(), + Migration23To24(), Migration24To23(), + Migration24To25(), ) fun MangaDatabase(context: Context): MangaDatabase = Room diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt index fc36085c7..a0d2dd63d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/Tables.kt @@ -7,3 +7,4 @@ const val TABLE_FAVOURITE_CATEGORIES = "favourite_categories" const val TABLE_HISTORY = "history" const val TABLE_MANGA_TAGS = "manga_tags" const val TABLE_SOURCES = "sources" +const val TABLE_CHAPTERS = "chapters" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/ChaptersDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/ChaptersDao.kt new file mode 100644 index 000000000..3a716b24d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/ChaptersDao.kt @@ -0,0 +1,30 @@ +package org.koitharu.kotatsu.core.db.dao + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Transaction +import org.koitharu.kotatsu.core.db.entity.ChapterEntity + +@Dao +abstract class ChaptersDao { + + @Query("SELECT * FROM chapters WHERE manga_id = :mangaId ORDER BY `index` ASC") + abstract suspend fun findAll(mangaId: Long): List + + @Query("DELETE FROM chapters WHERE manga_id = :mangaId") + abstract suspend fun deleteAll(mangaId: Long) + + @Query("DELETE FROM chapters WHERE manga_id NOT IN (SELECT manga_id FROM history WHERE deleted_at = 0) AND manga_id NOT IN (SELECT manga_id FROM favourites WHERE deleted_at = 0)") + abstract suspend fun gc() + + @Transaction + open suspend fun replaceAll(mangaId: Long, entities: Collection) { + deleteAll(mangaId) + insert(entities) + } + + @Insert(onConflict = OnConflictStrategy.REPLACE) + protected abstract suspend fun insert(entities: Collection) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt index 619c9befe..acc0eaa74 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaDao.kt @@ -20,6 +20,9 @@ abstract class MangaDao { @Query("SELECT * FROM manga WHERE manga_id = :id") abstract suspend fun find(id: Long): MangaWithTags? + @Query("SELECT EXISTS(SELECT * FROM manga WHERE manga_id = :id)") + abstract suspend operator fun contains(id: Long): Boolean + @Transaction @Query("SELECT * FROM manga WHERE public_url = :publicUrl") abstract suspend fun findByPublicUrl(publicUrl: String): MangaWithTags? @@ -55,6 +58,19 @@ abstract class MangaDao { @Delete abstract suspend fun delete(subjects: Collection) + @Query( + """ + DELETE FROM manga WHERE NOT EXISTS(SELECT * FROM history WHERE history.manga_id == manga.manga_id) + AND NOT EXISTS(SELECT * FROM favourites WHERE favourites.manga_id == manga.manga_id) + AND NOT EXISTS(SELECT * FROM bookmarks WHERE bookmarks.manga_id == manga.manga_id) + AND NOT EXISTS(SELECT * FROM suggestions WHERE suggestions.manga_id == manga.manga_id) + AND NOT EXISTS(SELECT * FROM scrobblings WHERE scrobblings.manga_id == manga.manga_id) + AND NOT EXISTS(SELECT * FROM local_index WHERE local_index.manga_id == manga.manga_id) + AND manga.manga_id NOT IN (:idsToKeep) + """, + ) + abstract suspend fun cleanup(idsToKeep: Set) + @Transaction open suspend fun upsert(manga: MangaEntity, tags: Iterable? = null) { upsert(manga) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt index ab1b77150..3e11fda1c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/dao/MangaSourcesDao.kt @@ -10,7 +10,6 @@ import androidx.room.Upsert import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteQuery import kotlinx.coroutines.flow.Flow -import org.intellij.lang.annotations.Language import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity import org.koitharu.kotatsu.explore.data.SourcesSortOrder @@ -61,21 +60,11 @@ abstract class MangaSourcesDao { @Query("SELECT * FROM sources WHERE pinned = 1") abstract suspend fun findAllPinned(): List - fun observeEnabled(order: SourcesSortOrder): Flow> { - val orderBy = getOrderBy(order) + fun observeAll(enabledOnly: Boolean, order: SourcesSortOrder): Flow> = + observeImpl(getQuery(enabledOnly, order)) - @Language("RoomSql") - val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy") - return observeImpl(query) - } - - suspend fun findAllEnabled(order: SourcesSortOrder): List { - val orderBy = getOrderBy(order) - - @Language("RoomSql") - val query = SimpleSQLiteQuery("SELECT * FROM sources WHERE enabled = 1 ORDER BY pinned DESC, $orderBy") - return findAllImpl(query) - } + suspend fun findAll(enabledOnly: Boolean, order: SourcesSortOrder): List = + findAllImpl(getQuery(enabledOnly, order)) @Transaction open suspend fun setEnabled(source: String, isEnabled: Boolean) { @@ -101,6 +90,17 @@ abstract class MangaSourcesDao { @RawQuery protected abstract suspend fun findAllImpl(query: SupportSQLiteQuery): List + private fun getQuery(enabledOnly: Boolean, order: SourcesSortOrder) = SimpleSQLiteQuery( + buildString { + append("SELECT * FROM sources ") + if (enabledOnly) { + append("WHERE enabled = 1 ") + } + append("ORDER BY pinned DESC, ") + append(getOrderBy(order)) + }, + ) + private fun getOrderBy(order: SourcesSortOrder) = when (order) { SourcesSortOrder.ALPHABETIC -> "source ASC" SourcesSortOrder.POPULARITY -> "(SELECT COUNT(*) FROM manga WHERE source = sources.source) DESC" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/ChapterEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/ChapterEntity.kt new file mode 100644 index 000000000..d1e026945 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/ChapterEntity.kt @@ -0,0 +1,32 @@ +package org.koitharu.kotatsu.core.db.entity + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import org.koitharu.kotatsu.core.db.TABLE_CHAPTERS + +@Entity( + tableName = TABLE_CHAPTERS, + primaryKeys = ["manga_id", "chapter_id"], + foreignKeys = [ + ForeignKey( + entity = MangaEntity::class, + parentColumns = ["manga_id"], + childColumns = ["manga_id"], + onDelete = ForeignKey.CASCADE, + ), + ], +) +data class ChapterEntity( + @ColumnInfo(name = "chapter_id") val chapterId: Long, + @ColumnInfo(name = "manga_id") val mangaId: Long, + @ColumnInfo(name = "name") val title: String, + @ColumnInfo(name = "number") val number: Float, + @ColumnInfo(name = "volume") val volume: Int, + @ColumnInfo(name = "url") val url: String, + @ColumnInfo(name = "scanlator") val scanlator: String?, + @ColumnInfo(name = "upload_date") val uploadDate: Long, + @ColumnInfo(name = "branch") val branch: String?, + @ColumnInfo(name = "source") val source: String, + @ColumnInfo(name = "index") val index: Int, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt index eda091c30..508b25f22 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/EntityMapping.kt @@ -1,14 +1,20 @@ package org.koitharu.kotatsu.core.db.entity import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.parsers.util.nullIfEmpty +import org.koitharu.kotatsu.parsers.util.toArraySet import org.koitharu.kotatsu.parsers.util.toTitleCase +private const val VALUES_DIVIDER = '\n' + // Entity to model fun TagEntity.toMangaTag() = MangaTag( @@ -21,26 +27,42 @@ fun Collection.toMangaTags() = mapToSet(TagEntity::toMangaTag) fun Collection.toMangaTagsList() = map(TagEntity::toMangaTag) -fun MangaEntity.toManga(tags: Set) = Manga( +fun MangaEntity.toManga(tags: Set, chapters: List?) = Manga( id = this.id, title = this.title, - altTitle = this.altTitle, + altTitles = this.altTitles?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(), state = this.state?.let { MangaState(it) }, rating = this.rating, - isNsfw = this.isNsfw, + contentRating = ContentRating(this.contentRating) + ?: if (isNsfw) ContentRating.ADULT else null, url = this.url, publicUrl = this.publicUrl, coverUrl = this.coverUrl, largeCoverUrl = this.largeCoverUrl, - author = this.author, + authors = this.authors?.split(VALUES_DIVIDER)?.toArraySet().orEmpty(), source = MangaSource(this.source), tags = tags, + chapters = chapters?.toMangaChapters(), ) -fun MangaWithTags.toManga() = manga.toManga(tags.toMangaTags()) +fun MangaWithTags.toManga(chapters: List? = null) = manga.toManga(tags.toMangaTags(), chapters) fun Collection.toMangaList() = map { it.toManga() } +fun ChapterEntity.toMangaChapter() = MangaChapter( + id = chapterId, + title = title.nullIfEmpty(), + number = number, + volume = volume, + url = url, + scanlator = scanlator, + uploadDate = uploadDate, + branch = branch, + source = MangaSource(source), +) + +fun Collection.toMangaChapters() = map { it.toMangaChapter() } + // Model to entity fun Manga.toEntity() = MangaEntity( @@ -50,12 +72,13 @@ fun Manga.toEntity() = MangaEntity( source = source.name, largeCoverUrl = largeCoverUrl, coverUrl = coverUrl.orEmpty(), - altTitle = altTitle, + altTitles = altTitles.joinToString(VALUES_DIVIDER.toString()), rating = rating, isNsfw = isNsfw, + contentRating = contentRating?.name, state = state?.name, title = title, - author = author, + authors = authors.joinToString(VALUES_DIVIDER.toString()), ) fun MangaTag.toEntity() = TagEntity( @@ -67,6 +90,22 @@ fun MangaTag.toEntity() = TagEntity( fun Collection.toEntities() = map(MangaTag::toEntity) +fun Iterable>.toEntities(mangaId: Long) = map { (index, chapter) -> + ChapterEntity( + chapterId = chapter.id, + mangaId = mangaId, + title = chapter.title.orEmpty(), + number = chapter.number, + volume = chapter.volume, + url = chapter.url, + scanlator = chapter.scanlator, + uploadDate = chapter.uploadDate, + branch = chapter.branch, + source = chapter.source.name, + index = index, + ) +} + // Other fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching { @@ -76,3 +115,7 @@ fun SortOrder(name: String, fallback: SortOrder): SortOrder = runCatching { fun MangaState(name: String): MangaState? = runCatching { MangaState.valueOf(name) }.getOrNull() + +fun ContentRating(name: String?): ContentRating? = runCatching { + ContentRating.valueOf(name ?: return@runCatching null) +}.getOrNull() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt index 9156db7b7..fd4a23015 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/entity/MangaEntity.kt @@ -10,14 +10,15 @@ data class MangaEntity( @PrimaryKey(autoGenerate = false) @ColumnInfo(name = "manga_id") val id: Long, @ColumnInfo(name = "title") val title: String, - @ColumnInfo(name = "alt_title") val altTitle: String?, + @ColumnInfo(name = "alt_title") val altTitles: String?, @ColumnInfo(name = "url") val url: String, @ColumnInfo(name = "public_url") val publicUrl: String, @ColumnInfo(name = "rating") val rating: Float, // normalized value [0..1] or -1 - @ColumnInfo(name = "nsfw") val isNsfw: Boolean, // TODO change to contentRating + @ColumnInfo(name = "nsfw") val isNsfw: Boolean, + @ColumnInfo(name = "content_rating") val contentRating: String?, @ColumnInfo(name = "cover_url") val coverUrl: String, @ColumnInfo(name = "large_cover_url") val largeCoverUrl: String?, @ColumnInfo(name = "state") val state: String?, - @ColumnInfo(name = "author") val author: String?, + @ColumnInfo(name = "author") val authors: String?, @ColumnInfo(name = "source") val source: String, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration23To24.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration23To24.kt new file mode 100644 index 000000000..008b1f158 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration23To24.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration23To24 : Migration(23, 24) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("CREATE TABLE IF NOT EXISTS `chapters` (`chapter_id` INTEGER NOT NULL, `manga_id` INTEGER NOT NULL, `name` TEXT NOT NULL, `number` REAL NOT NULL, `volume` INTEGER NOT NULL, `url` TEXT NOT NULL, `scanlator` TEXT, `upload_date` INTEGER NOT NULL, `branch` TEXT, `source` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`manga_id`, `chapter_id`), FOREIGN KEY(`manga_id`) REFERENCES `manga`(`manga_id`) ON UPDATE NO ACTION ON DELETE CASCADE )") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration24To25.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration24To25.kt new file mode 100644 index 000000000..592996eed --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/migrations/Migration24To25.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.db.migrations + +import androidx.room.migration.Migration +import androidx.sqlite.db.SupportSQLiteDatabase + +class Migration24To25 : Migration(24, 25) { + + override fun migrate(db: SupportSQLiteDatabase) { + db.execSQL("ALTER TABLE manga ADD COLUMN content_rating TEXT DEFAULT NULL") + db.execSQL("UPDATE manga SET content_rating = 'ADULT' WHERE nsfw = 1") + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt index 8d2e34b9f..27757b557 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/DialogErrorObserver.kt @@ -6,7 +6,6 @@ import androidx.core.util.Consumer import androidx.fragment.app.Fragment import com.google.android.material.dialog.MaterialAlertDialogBuilder import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.parsers.exception.ParseException @@ -32,10 +31,10 @@ class DialogErrorObserver( if (canResolve(value)) { dialogBuilder.setPositiveButton(ExceptionResolver.getResolveStringId(value), listener) } else if (value is ParseException) { - val fm = fragmentManager - if (fm != null && value.isSerializable()) { + val router = router() + if (router != null && value.isSerializable()) { dialogBuilder.setPositiveButton(R.string.details) { _, _ -> - ErrorDetailsDialog.show(fm, value, value.url) + router.showErrorDialog(value) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt index 097c562a5..8b91c0efa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ErrorObserver.kt @@ -4,6 +4,7 @@ import android.view.View import androidx.appcompat.app.AppCompatActivity import androidx.core.util.Consumer import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleCoroutineScope import androidx.lifecycle.LifecycleOwner @@ -11,6 +12,7 @@ import androidx.lifecycle.coroutineScope import kotlinx.coroutines.flow.FlowCollector import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope @@ -33,6 +35,8 @@ abstract class ErrorObserver( return resolver != null && ExceptionResolver.canResolve(error) } + protected fun router() = fragment?.router ?: (activity as? FragmentActivity)?.router + private fun isAlive(): Boolean { return when { fragment != null -> fragment.view != null @@ -44,7 +48,7 @@ abstract class ErrorObserver( protected fun resolve(error: Throwable) { if (isAlive()) { lifecycleScope.launch { - val isResolved = resolver?.resolve(error) ?: false + val isResolved = resolver?.resolve(error) == true if (isActive) { onResolved?.accept(isResolved) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt index d843dd0d6..6031e255f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/ExceptionResolver.kt @@ -5,19 +5,20 @@ import android.widget.Toast import androidx.activity.result.ActivityResultCaller import androidx.annotation.StringRes import androidx.collection.MutableScatterMap +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity -import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.exceptions.ProxyConfigException import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.util.ext.restartApplication import org.koitharu.kotatsu.parsers.exception.AuthRequiredException @@ -26,7 +27,6 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper -import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import java.security.cert.CertPathValidatorException import javax.inject.Provider @@ -49,8 +49,8 @@ class ExceptionResolver @AssistedInject constructor( handleActivityResult(CloudFlareActivity.TAG, it) } - fun showDetails(e: Throwable, url: String?) { - ErrorDetailsDialog.show(host.getChildFragmentManager(), e, url) + fun showErrorDetails(e: Throwable, url: String? = null) { + host.router()?.showErrorDialog(e, url) } suspend fun resolve(e: Throwable): Boolean = when (e) { @@ -63,9 +63,7 @@ class ExceptionResolver @AssistedInject constructor( } is ProxyConfigException -> { - host.withContext { - startActivity(SettingsActivity.newProxySettingsIntent(this)) - } + host.router()?.openProxySettings() false } @@ -85,9 +83,7 @@ class ExceptionResolver @AssistedInject constructor( true } else { host.withContext { - authHelper.startAuth(this, e.scrobbler).onFailure { - showDetails(it, null) - } + authHelper.startAuth(this, e.scrobbler).onFailure(::showErrorDetails) } false } @@ -106,12 +102,12 @@ class ExceptionResolver @AssistedInject constructor( sourceAuthContract.launch(source) } - private fun openInBrowser(url: String) = host.withContext { - startActivity(BrowserActivity.newIntent(this, url, null, null)) + private fun openInBrowser(url: String) { + host.router()?.openBrowser(url, null, null) } - private fun openAlternatives(manga: Manga) = host.withContext { - startActivity(AlternativesActivity.newIntent(this, manga)) + private fun openAlternatives(manga: Manga) { + host.router()?.openAlternatives(manga) } private fun handleActivityResult(tag: String, result: Boolean) { @@ -140,6 +136,12 @@ class ExceptionResolver @AssistedInject constructor( getContext()?.apply(block) } + private fun Host.router(): AppRouter? = when (this) { + is FragmentActivity -> router + is Fragment -> router + else -> null + } + interface Host : ActivityResultCaller { fun getChildFragmentManager(): FragmentManager diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt index d5b55b750..bec64d18c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/exceptions/resolve/SnackbarErrorObserver.kt @@ -5,7 +5,6 @@ import androidx.core.util.Consumer import androidx.fragment.app.Fragment import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.isSerializable import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner @@ -33,10 +32,10 @@ class SnackbarErrorObserver( resolve(value) } } else if (value is ParseException) { - val fm = fragmentManager - if (fm != null && value.isSerializable()) { + val router = router() + if (router != null && value.isSerializable()) { snackbar.setAction(R.string.details) { - ErrorDetailsDialog.show(fm, value, value.url) + router.showErrorDialog(value) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt index 4fb9d0c84..674307bc1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/github/AppUpdateRepository.kt @@ -21,6 +21,7 @@ import org.koitharu.kotatsu.parsers.util.await import org.koitharu.kotatsu.parsers.util.json.mapJSONNotNull import org.koitharu.kotatsu.parsers.util.parseJsonArray import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull import javax.inject.Inject import javax.inject.Singleton @@ -42,6 +43,9 @@ class AppUpdateRepository @Inject constructor( append("/releases?page=1&per_page=10") } + val isUpdateAvailable: Boolean + get() = availableUpdate.value != null + fun observeAvailableUpdate() = availableUpdate.asStateFlow() suspend fun getAvailableVersions(): List { @@ -85,8 +89,8 @@ class AppUpdateRepository @Inject constructor( } @Suppress("KotlinConstantConditions") - fun isUpdateSupported(): Boolean { - return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp + suspend fun isUpdateSupported(): Boolean { + return BuildConfig.BUILD_TYPE != BUILD_TYPE_RELEASE || appValidator.isOriginalApp.getOrNull() == true } suspend fun getCurrentVersionChangelog(): String? { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt index 34584ec57..dabb69050 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/BitmapDecoderCompat.kt @@ -4,26 +4,26 @@ import android.graphics.Bitmap import android.graphics.BitmapFactory import android.graphics.ImageDecoder import android.os.Build -import android.webkit.MimeTypeMap import androidx.annotation.RequiresApi import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException -import okhttp3.MediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull import org.aomedia.avif.android.AvifDecoder import org.aomedia.avif.android.AvifDecoder.Info import org.jetbrains.annotations.Blocking +import org.koitharu.kotatsu.core.util.MimeTypes +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.toByteBuffer +import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import java.io.File import java.io.InputStream import java.nio.ByteBuffer -import java.nio.file.Files object BitmapDecoderCompat { private const val FORMAT_AVIF = "avif" @Blocking - fun decode(file: File): Bitmap = when (val format = getMimeType(file)?.subtype) { + fun decode(file: File): Bitmap = when (val format = probeMimeType(file)?.subtype) { FORMAT_AVIF -> file.inputStream().use { decodeAvif(it.toByteBuffer()) } else -> if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { ImageDecoder.decodeBitmap(ImageDecoder.createSource(file)) @@ -33,7 +33,7 @@ object BitmapDecoderCompat { } @Blocking - fun decode(stream: InputStream, type: MediaType?, isMutable: Boolean = false): Bitmap { + fun decode(stream: InputStream, type: MimeType?, isMutable: Boolean = false): Bitmap { val format = type?.subtype if (format == FORMAT_AVIF) { return decodeAvif(stream.toByteBuffer()) @@ -51,12 +51,20 @@ object BitmapDecoderCompat { } } - private fun getMimeType(file: File): MediaType? = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Files.probeContentType(file.toPath())?.toMediaTypeOrNull() - } else { - MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension)?.toMediaTypeOrNull() + @Blocking + fun probeMimeType(file: File): MimeType? { + return MimeTypes.probeMimeType(file) ?: detectBitmapType(file) } + @Blocking + private fun detectBitmapType(file: File): MimeType? = runCatchingCancellable { + val options = BitmapFactory.Options().apply { + inJustDecodeBounds = true + } + BitmapFactory.decodeFile(file.path, options)?.recycle() + return options.outMimeType?.toMimeTypeOrNull() + }.getOrNull() + private fun checkBitmapNotNull(bitmap: Bitmap?, format: String?): Bitmap = bitmap ?: throw ImageDecodeException(null, format) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt index 9f52ff8c0..c2a2561fd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/image/CbzFetcher.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.core.image import android.net.Uri -import android.webkit.MimeTypeMap import coil3.ImageLoader import coil3.decode.DataSource import coil3.decode.ImageSource @@ -12,6 +11,7 @@ import coil3.toAndroidUri import kotlinx.coroutines.runInterruptible import okio.Path.Companion.toPath import okio.openZip +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.isZipUri import coil3.Uri as CoilUri @@ -23,9 +23,10 @@ class CbzFetcher( override suspend fun fetch() = runInterruptible { val filePath = uri.schemeSpecificPart.toPath() val entryName = requireNotNull(uri.fragment) + val fs = options.fileSystem.openZip(filePath) SourceFetchResult( - source = ImageSource(entryName.toPath(), options.fileSystem.openZip(filePath)), - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(entryName.substringAfterLast('.', "")), + source = ImageSource(entryName.toPath(), fs, closeable = fs), + mimeType = MimeTypes.getMimeTypeFromExtension(entryName)?.toString(), dataSource = DataSource.DISK, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt index a077ee19a..10304ef2b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/Manga.kt @@ -1,10 +1,12 @@ package org.koitharu.kotatsu.core.model +import android.content.res.Resources import android.net.Uri import android.text.SpannableStringBuilder import androidx.annotation.DrawableRes import androidx.annotation.StringRes import androidx.collection.MutableObjectIntMap +import androidx.core.net.toUri import androidx.core.os.LocaleListCompat import androidx.core.text.buildSpannedString import androidx.core.text.strikeThrough @@ -124,7 +126,8 @@ val Manga.isBroken: Boolean get() = source == UnknownMangaSource val Manga.appUrl: Uri - get() = Uri.parse("https://kotatsu.app/manga").buildUpon() + get() = "https://kotatsu.app/manga".toUri() + .buildUpon() .appendQueryParameter("source", source.name) .appendQueryParameter("name", title) .appendQueryParameter("url", url) @@ -168,3 +171,24 @@ private fun SpannableStringBuilder.appendTagsSummary(filter: MangaListFilter) { } } } + +fun MangaChapter.getLocalizedTitle(resources: Resources, index: Int = -1): String { + title?.let { + if (it.isNotBlank()) { + return it + } + } + val num = numberString() + val vol = volumeString() + return when { + num != null && vol != null -> resources.getString(R.string.chapter_volume_number, vol, num) + num != null -> resources.getString(R.string.chapter_number, num) + index > 0 -> resources.getString( + R.string.chapters_time_pattern, + resources.getString(R.string.unnamed_chapter), + index.toString(), + ) + + else -> resources.getString(R.string.unnamed_chapter) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt index ce77eed36..0abf3138d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/MangaSource.kt @@ -2,11 +2,16 @@ package org.koitharu.kotatsu.core.model import android.content.Context import android.graphics.Color +import android.os.Build import android.text.SpannableStringBuilder import android.text.style.ForegroundColorSpan +import android.text.style.ImageSpan import android.text.style.RelativeSizeSpan import android.text.style.SuperscriptSpan +import android.widget.TextView +import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.core.content.ContextCompat import androidx.core.text.inSpans import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource @@ -100,3 +105,16 @@ fun SpannableStringBuilder.appendNsfwLabel(context: Context) = inSpans( ) { append(context.getString(R.string.nsfw)) } + +fun SpannableStringBuilder.appendIcon(textView: TextView, @DrawableRes resId: Int): SpannableStringBuilder { + val icon = ContextCompat.getDrawable(textView.context, resId) ?: return this + icon.setTintList(textView.textColors) + val size = textView.lineHeight + icon.setBounds(0, 0, size, size) + val alignment = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ImageSpan.ALIGN_CENTER + } else { + ImageSpan.ALIGN_BOTTOM + } + return inSpans(ImageSpan(icon, alignment)) { append(' ') } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/QuickFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/QuickFilter.kt new file mode 100644 index 000000000..c91e32810 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/QuickFilter.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.core.model + +import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.list.domain.ListFilterOption + +fun ListFilterOption.toChipModel(isChecked: Boolean) = ChipsView.ChipModel( + title = titleText, + titleResId = titleResId, + icon = iconResId, + iconData = getIconData(), + isChecked = isChecked, + data = this, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableChapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableChapter.kt index 148060125..a28796f60 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableChapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableChapter.kt @@ -17,7 +17,7 @@ data class ParcelableChapter( override fun create(parcel: Parcel) = ParcelableChapter( MangaChapter( id = parcel.readLong(), - name = parcel.readString().orEmpty(), + title = parcel.readString(), number = parcel.readFloat(), volume = parcel.readInt(), url = parcel.readString().orEmpty(), @@ -30,7 +30,7 @@ data class ParcelableChapter( override fun ParcelableChapter.write(parcel: Parcel, flags: Int) = with(chapter) { parcel.writeLong(id) - parcel.writeString(name) + parcel.writeString(title) parcel.writeFloat(number) parcel.writeInt(volume) parcel.writeString(url) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt index 58b28adde..5d8824be1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableManga.kt @@ -7,6 +7,8 @@ import kotlinx.parcelize.Parcelize import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.util.ext.readParcelableCompat import org.koitharu.kotatsu.core.util.ext.readSerializableCompat +import org.koitharu.kotatsu.core.util.ext.readStringSet +import org.koitharu.kotatsu.core.util.ext.writeStringSet import org.koitharu.kotatsu.parsers.model.Manga @Parcelize @@ -20,7 +22,7 @@ data class ParcelableManga( override fun ParcelableManga.write(parcel: Parcel, flags: Int) = with(manga) { parcel.writeLong(id) parcel.writeString(title) - parcel.writeString(altTitle) + parcel.writeStringSet(altTitles) parcel.writeString(url) parcel.writeString(publicUrl) parcel.writeFloat(rating) @@ -30,7 +32,7 @@ data class ParcelableManga( parcel.writeString(description.takeIf { withDescription }) parcel.writeParcelable(ParcelableMangaTags(tags), flags) parcel.writeSerializable(state) - parcel.writeString(author) + parcel.writeStringSet(authors) parcel.writeString(source.name) } @@ -38,7 +40,7 @@ data class ParcelableManga( Manga( id = parcel.readLong(), title = requireNotNull(parcel.readString()), - altTitle = parcel.readString(), + altTitles = parcel.readStringSet(), url = requireNotNull(parcel.readString()), publicUrl = requireNotNull(parcel.readString()), rating = parcel.readFloat(), @@ -48,7 +50,7 @@ data class ParcelableManga( description = parcel.readString(), tags = requireNotNull(parcel.readParcelableCompat()).tags, state = parcel.readSerializableCompat(), - author = parcel.readString(), + authors = parcel.readStringSet(), chapters = null, source = MangaSource(parcel.readString()), ), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaListFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaListFilter.kt index 6b0eb85ed..6b48191f8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaListFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/model/parcelable/ParcelableMangaListFilter.kt @@ -30,6 +30,7 @@ object MangaListFilterParceler : Parceler { parcel.writeInt(year) parcel.writeInt(yearFrom) parcel.writeInt(yearTo) + parcel.writeString(author) } override fun create(parcel: Parcel) = MangaListFilter( @@ -45,6 +46,7 @@ object MangaListFilterParceler : Parceler { year = parcel.readInt(), yearFrom = parcel.readInt(), yearTo = parcel.readInt(), + author = parcel.readString(), ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt new file mode 100644 index 000000000..0e6919c20 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt @@ -0,0 +1,803 @@ +package org.koitharu.kotatsu.core.nav + +import android.accounts.Account +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.DialogInterface +import android.content.Intent +import android.net.Uri +import android.os.Bundle +import android.provider.Settings +import android.view.View +import androidx.annotation.CheckResult +import androidx.annotation.UiContext +import androidx.core.app.ShareCompat +import androidx.core.content.FileProvider +import androidx.core.net.toUri +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.findFragment +import androidx.lifecycle.LifecycleOwner +import dagger.hilt.android.EntryPointAccessors +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity +import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity +import org.koitharu.kotatsu.browser.BrowserActivity +import org.koitharu.kotatsu.browser.cloudflare.CloudFlareActivity +import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException +import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.model.MangaSourceInfo +import org.koitharu.kotatsu.core.model.appUrl +import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.model.isBroken +import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter +import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ReaderMode +import org.koitharu.kotatsu.core.prefs.TriStateOption +import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog +import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog +import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog +import org.koitharu.kotatsu.core.util.ext.connectivityManager +import org.koitharu.kotatsu.core.util.ext.findActivity +import org.koitharu.kotatsu.core.util.ext.getThemeDrawable +import org.koitharu.kotatsu.core.util.ext.toFileOrNull +import org.koitharu.kotatsu.core.util.ext.toUriOrNull +import org.koitharu.kotatsu.core.util.ext.withArgs +import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet +import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity +import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingInfoSheet +import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment +import org.koitharu.kotatsu.download.ui.list.DownloadsActivity +import org.koitharu.kotatsu.favourites.ui.FavouritesActivity +import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity +import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity +import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteDialog +import org.koitharu.kotatsu.filter.ui.FilterCoordinator +import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment +import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet +import org.koitharu.kotatsu.history.ui.HistoryActivity +import org.koitharu.kotatsu.image.ui.ImageActivity +import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet +import org.koitharu.kotatsu.list.ui.config.ListConfigSection +import org.koitharu.kotatsu.local.ui.ImportDialogFragment +import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog +import org.koitharu.kotatsu.main.ui.MainActivity +import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaPage +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.ellipsize +import org.koitharu.kotatsu.parsers.util.isNullOrEmpty +import org.koitharu.kotatsu.parsers.util.mapToArray +import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity +import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet +import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService +import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity +import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet +import org.koitharu.kotatsu.search.domain.SearchKind +import org.koitharu.kotatsu.search.ui.MangaListActivity +import org.koitharu.kotatsu.search.ui.multi.SearchActivity +import org.koitharu.kotatsu.settings.SettingsActivity +import org.koitharu.kotatsu.settings.about.AppUpdateActivity +import org.koitharu.kotatsu.settings.backup.BackupDialogFragment +import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment +import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity +import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity +import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity +import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog +import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity +import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet +import org.koitharu.kotatsu.stats.ui.StatsActivity +import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet +import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity +import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity +import java.io.File +import com.google.android.material.R as materialR + +class AppRouter private constructor( + private val activity: FragmentActivity?, + private val fragment: Fragment?, +) { + + constructor(activity: FragmentActivity) : this(activity, null) + + constructor(fragment: Fragment) : this(null, fragment) + + private val settings: AppSettings by lazy { + EntryPointAccessors.fromApplication(checkNotNull(contextOrNull())).settings + } + + /** Activities **/ + + fun openList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) { + startActivity(listIntent(contextOrNull() ?: return, source, filter, sortOrder)) + } + + fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)), null) + + fun openSearch(query: String, kind: SearchKind = SearchKind.SIMPLE) { + startActivity( + Intent(contextOrNull() ?: return, SearchActivity::class.java) + .putExtra(KEY_QUERY, query) + .putExtra(KEY_KIND, kind), + ) + } + + fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query), null) + + fun openDetails(manga: Manga) { + startActivity(detailsIntent(contextOrNull() ?: return, manga)) + } + + fun openDetails(mangaId: Long) { + startActivity(detailsIntent(contextOrNull() ?: return, mangaId)) + } + + fun openDetails(link: Uri) { + startActivity( + Intent(contextOrNull() ?: return, DetailsActivity::class.java) + .setData(link), + ) + } + + fun openReader(manga: Manga, anchor: View? = null) { + openReader( + ReaderIntent.Builder(contextOrNull() ?: return) + .manga(manga) + .build(), + anchor, + ) + } + + fun openReader(intent: ReaderIntent, anchor: View? = null) { + startActivity(intent.intent, anchor?.let { view -> scaleUpActivityOptionsOf(view) }) + } + + fun openAlternatives(manga: Manga) { + startActivity( + Intent(contextOrNull() ?: return, AlternativesActivity::class.java) + .putExtra(KEY_MANGA, ParcelableManga(manga)), + ) + } + + fun openRelated(manga: Manga) { + startActivity( + Intent(contextOrNull(), RelatedMangaActivity::class.java) + .putExtra(KEY_MANGA, ParcelableManga(manga)), + ) + } + + fun openImage(url: String, source: MangaSource?, anchor: View? = null) { + startActivity( + Intent(contextOrNull(), ImageActivity::class.java) + .setData(url.toUri()) + .putExtra(KEY_SOURCE, source?.name), + anchor?.let { scaleUpActivityOptionsOf(it) }, + ) + } + + fun openBookmarks() = startActivity(AllBookmarksActivity::class.java) + + fun openAppUpdate() = startActivity(AppUpdateActivity::class.java) + + fun openSuggestions() { + startActivity(suggestionsIntent(contextOrNull() ?: return)) + } + + fun openSourcesCatalog() = startActivity(SourcesCatalogActivity::class.java) + + fun openDownloads() = startActivity(DownloadsActivity::class.java) + + fun openDirectoriesSettings() = startActivity(MangaDirectoriesActivity::class.java) + + fun openBrowser(url: String, source: MangaSource?, title: String?) { + startActivity( + Intent(contextOrNull() ?: return, BrowserActivity::class.java) + .setData(url.toUri()) + .putExtra(KEY_TITLE, title) + .putExtra(KEY_SOURCE, source?.name), + ) + } + + fun openColorFilterConfig(manga: Manga, page: MangaPage) { + startActivity( + Intent(contextOrNull(), ColorFilterConfigActivity::class.java) + .putExtra(KEY_MANGA, ParcelableManga(manga)) + .putExtra(KEY_PAGES, ParcelableMangaPage(page)), + ) + } + + fun openHistory() = startActivity(HistoryActivity::class.java) + + fun openFavorites() = startActivity(FavouritesActivity::class.java) + + fun openFavorites(category: FavouriteCategory) { + startActivity( + Intent(contextOrNull() ?: return, FavouritesActivity::class.java) + .putExtra(KEY_ID, category.id) + .putExtra(KEY_TITLE, category.title), + ) + } + + fun openFavoriteCategories() = startActivity(FavouriteCategoriesActivity::class.java) + + fun openFavoriteCategoryEdit(categoryId: Long) { + startActivity( + Intent(contextOrNull() ?: return, FavouritesCategoryEditActivity::class.java) + .putExtra(KEY_ID, categoryId), + ) + } + + fun openFavoriteCategoryCreate() = openFavoriteCategoryEdit(FavouritesCategoryEditActivity.NO_ID) + + fun openMangaUpdates() { + startActivity(mangaUpdatesIntent(contextOrNull() ?: return)) + } + + fun openSettings() = startActivity(SettingsActivity::class.java) + + fun openReaderSettings() { + startActivity(readerSettingsIntent(contextOrNull() ?: return)) + } + + fun openProxySettings() { + startActivity(proxySettingsIntent(contextOrNull() ?: return)) + } + + fun openDownloadsSetting() { + startActivity(downloadsSettingsIntent(contextOrNull() ?: return)) + } + + fun openSourceSettings(source: MangaSource) { + startActivity(sourceSettingsIntent(contextOrNull() ?: return, source)) + } + + fun openSuggestionsSettings() { + startActivity(suggestionsSettingsIntent(contextOrNull() ?: return)) + } + + fun openSourcesSettings() { + startActivity(sourcesSettingsIntent(contextOrNull() ?: return)) + } + + fun openReaderTapGridSettings() = startActivity(ReaderTapGridConfigActivity::class.java) + + fun openScrobblerSettings(scrobbler: ScrobblerService) { + startActivity( + Intent(contextOrNull() ?: return, ScrobblerConfigActivity::class.java) + .putExtra(KEY_ID, scrobbler.id), + ) + } + + fun openSourceAuth(source: MangaSource) { + startActivity(sourceAuthIntent(contextOrNull() ?: return, source)) + } + + fun openManageSources() { + startActivity( + manageSourcesIntent(contextOrNull() ?: return), + ) + } + + fun openStatistic() = startActivity(StatsActivity::class.java) + + @CheckResult + fun openExternalBrowser(url: String, chooserTitle: CharSequence? = null): Boolean { + val intent = Intent(Intent.ACTION_VIEW) + intent.data = url.toUriOrNull() ?: return false + return startActivitySafe( + if (!chooserTitle.isNullOrEmpty()) { + Intent.createChooser(intent, chooserTitle) + } else { + intent + }, + ) + } + + @CheckResult + fun openSystemSyncSettings(account: Account): Boolean { + val args = Bundle(1) + args.putParcelable(ACCOUNT_KEY, account) + val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS) + intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args) + return startActivitySafe(intent) + } + + /** Dialogs **/ + + fun showDownloadDialog(manga: Manga, snackbarHost: View?) = showDownloadDialog(setOf(manga), snackbarHost) + + fun showDownloadDialog(manga: Collection, snackbarHost: View?) { + if (manga.isEmpty()) { + return + } + val fm = getFragmentManager() ?: return + if (snackbarHost != null) { + getLifecycleOwner()?.let { lifecycleOwner -> + DownloadDialogFragment.registerCallback(fm, lifecycleOwner, snackbarHost) + } + } else { + DownloadDialogFragment.unregisterCallback(fm) + } + DownloadDialogFragment().withArgs(1) { + putParcelableArray(KEY_MANGA, manga.mapToArray { ParcelableManga(it, withDescription = false) }) + }.showDistinct() + } + + fun showLocalInfoDialog(manga: Manga) { + LocalInfoDialog().withArgs(1) { + putParcelable(KEY_MANGA, ParcelableManga(manga)) + }.showDistinct() + } + + fun showDirectorySelectDialog() { + MangaDirectorySelectDialog().showDistinct() + } + + fun showFavoriteDialog(manga: Manga) = showFavoriteDialog(setOf(manga)) + + fun showFavoriteDialog(manga: Collection) { + if (manga.isEmpty()) { + return + } + FavoriteDialog().withArgs(1) { + putParcelableArrayList( + KEY_MANGA_LIST, + manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) }, + ) + }.showDistinct() + } + + fun showTagDialog(tag: MangaTag) { + buildAlertDialog(contextOrNull() ?: return) { + setIcon(R.drawable.ic_tag) + setTitle(tag.title) + setItems( + arrayOf( + context.getString(R.string.search_on_s, tag.source.getTitle(context)), + context.getString(R.string.search_everywhere), + ), + ) { _, which -> + when (which) { + 0 -> openList(tag) + 1 -> openSearch(tag.title, SearchKind.TAG) + } + } + setNegativeButton(R.string.close, null) + setCancelable(true) + }.show() + } + + fun showAuthorDialog(author: String, source: MangaSource) { + buildAlertDialog(contextOrNull() ?: return) { + setIcon(R.drawable.ic_user) + setTitle(author) + setItems( + arrayOf( + context.getString(R.string.search_on_s, source.getTitle(context)), + context.getString(R.string.search_everywhere), + ), + ) { _, which -> + when (which) { + 0 -> openList(source, MangaListFilter(author = author), null) + 1 -> openSearch(author, SearchKind.AUTHOR) + } + } + setNegativeButton(R.string.close, null) + setCancelable(true) + }.show() + } + + fun showShareDialog(manga: Manga) { + if (manga.isBroken) { + return + } + if (manga.isLocal) { + manga.url.toUri().toFileOrNull()?.let { + shareFile(it) + } + return + } + buildAlertDialog(contextOrNull() ?: return) { + setIcon(context.getThemeDrawable(materialR.attr.actionModeShareDrawable)) + setTitle(R.string.share) + setItems( + arrayOf( + context.getString(R.string.link_to_manga_in_app), + context.getString(R.string.link_to_manga_on_s, manga.source.getTitle(context)), + ), + ) { _, which -> + val link = when (which) { + 0 -> manga.appUrl.toString() + 1 -> manga.publicUrl + else -> return@setItems + } + shareLink(link, manga.title) + } + setNegativeButton(android.R.string.cancel, null) + setCancelable(true) + }.show() + } + + fun showErrorDialog(error: Throwable, url: String? = null) { + ErrorDetailsDialog().withArgs(2) { + putSerializable(KEY_ERROR, error) + putString(KEY_URL, url) + }.show() + } + + fun showBackupRestoreDialog(fileUri: Uri) { + RestoreDialogFragment().withArgs(1) { + putString(KEY_FILE, fileUri.toString()) + }.show() + } + + fun showBackupCreateDialog() { + BackupDialogFragment().show() + } + + fun showImportDialog() { + ImportDialogFragment().showDistinct() + } + + fun showFilterSheet(): Boolean = if (isFilterSupported()) { + FilterSheetFragment().showDistinct() + } else { + false + } + + fun showTagsCatalogSheet(excludeMode: Boolean) { + if (!isFilterSupported()) { + return + } + TagsCatalogSheet().withArgs(1) { + putBoolean(KEY_EXCLUDE, excludeMode) + }.showDistinct() + } + + fun showListConfigSheet(section: ListConfigSection) { + ListConfigBottomSheet().withArgs(1) { + putParcelable(KEY_LIST_SECTION, section) + }.showDistinct() + } + + fun showStatisticSheet(manga: Manga) { + MangaStatsSheet().withArgs(1) { + putParcelable(KEY_MANGA, ParcelableManga(manga)) + }.showDistinct() + } + + fun showReaderConfigSheet(mode: ReaderMode) { + ReaderConfigSheet().withArgs(1) { + putInt(KEY_READER_MODE, mode.id) + }.showDistinct() + } + + fun showWelcomeSheet() { + WelcomeSheet().showDistinct() + } + + fun showChapterPagesSheet() { + ChaptersPagesSheet().showDistinct() + } + + fun showChapterPagesSheet(defaultTab: Int) { + ChaptersPagesSheet().withArgs(1) { + putInt(KEY_TAB, defaultTab) + }.showDistinct() + } + + fun showScrobblingSelectorSheet(manga: Manga, scrobblerService: ScrobblerService?) { + ScrobblingSelectorSheet().withArgs(2) { + putParcelable(KEY_MANGA, ParcelableManga(manga)) + if (scrobblerService != null) { + putInt(KEY_ID, scrobblerService.id) + } + }.show() + } + + fun showScrobblingInfoSheet(index: Int) { + ScrobblingInfoSheet().withArgs(1) { + putInt(KEY_INDEX, index) + }.showDistinct() + } + + fun showTrackerCategoriesConfigSheet() { + TrackerCategoriesConfigSheet().showDistinct() + } + + fun askForDownloadOverMeteredNetwork(onConfirmed: (allow: Boolean) -> Unit) { + val context = contextOrNull() ?: return + when (settings.allowDownloadOnMeteredNetwork) { + TriStateOption.ENABLED -> onConfirmed(true) + TriStateOption.DISABLED -> onConfirmed(false) + TriStateOption.ASK -> { + if (!context.connectivityManager.isActiveNetworkMetered) { + onConfirmed(true) + return + } + val listener = DialogInterface.OnClickListener { _, which -> + when (which) { + DialogInterface.BUTTON_POSITIVE -> { + settings.allowDownloadOnMeteredNetwork = TriStateOption.ENABLED + onConfirmed(true) + } + + DialogInterface.BUTTON_NEUTRAL -> { + onConfirmed(true) + } + + DialogInterface.BUTTON_NEGATIVE -> { + settings.allowDownloadOnMeteredNetwork = TriStateOption.DISABLED + onConfirmed(false) + } + } + } + BigButtonsAlertDialog.Builder(context) + .setIcon(R.drawable.ic_network_cellular) + .setTitle(R.string.download_cellular_confirm) + .setPositiveButton(R.string.allow_always, listener) + .setNeutralButton(R.string.allow_once, listener) + .setNegativeButton(R.string.dont_allow, listener) + .create() + .show() + } + } + } + + /** Public utils **/ + + fun isFilterSupported(): Boolean = when { + fragment != null -> fragment.activity is FilterCoordinator.Owner + activity != null -> activity is FilterCoordinator.Owner + else -> false + } + + fun isChapterPagesSheetShown(): Boolean { + val sheet = getFragmentManager()?.findFragmentByTag(fragmentTag()) as? ChaptersPagesSheet + return sheet?.dialog?.isShowing == true + } + + fun closeWelcomeSheet(): Boolean { + val tag = fragmentTag() + val sheet = fragment?.findFragmentByTagRecursive(tag) + ?: activity?.supportFragmentManager?.findFragmentByTag(tag) + ?: return false + return if (sheet is WelcomeSheet) { + sheet.dismissAllowingStateLoss() + true + } else { + false + } + } + + /** Private utils **/ + + private fun startActivity(intent: Intent, options: Bundle? = null) { + fragment?.startActivity(intent, options) + ?: activity?.startActivity(intent, options) + } + + private fun startActivitySafe(intent: Intent): Boolean = try { + startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + false + } + + private fun startActivity(activityClass: Class) { + startActivity(Intent(contextOrNull() ?: return, activityClass)) + } + + private fun getFragmentManager(): FragmentManager? { + return fragment?.childFragmentManager ?: activity?.supportFragmentManager + } + + private fun shareLink(link: String, title: String) { + val context = contextOrNull() ?: return + ShareCompat.IntentBuilder(context) + .setText(link) + .setType(TYPE_TEXT) + .setChooserTitle(context.getString(R.string.share_s, title.ellipsize(12))) + .startChooser() + } + + private fun shareFile(file: File) { // TODO directory sharing support + val context = contextOrNull() ?: return + val intentBuilder = ShareCompat.IntentBuilder(context) + .setType(TYPE_CBZ) + val uri = FileProvider.getUriForFile(context, "${BuildConfig.APPLICATION_ID}.files", file) + intentBuilder.addStream(uri) + intentBuilder.setChooserTitle(context.getString(R.string.share_s, file.name)) + intentBuilder.startChooser() + } + + @UiContext + private fun contextOrNull(): Context? = activity ?: fragment?.context + + private fun getLifecycleOwner(): LifecycleOwner? = activity ?: fragment?.viewLifecycleOwner + + private fun DialogFragment.showDistinct(): Boolean { + val fm = this@AppRouter.getFragmentManager() ?: return false + val tag = javaClass.fragmentTag() + val existing = fm.findFragmentByTag(tag) as? DialogFragment? + if (existing != null && existing.isVisible && existing.arguments == this.arguments) { + return false + } + show(fm, tag) + return true + } + + private fun DialogFragment.show() { + show( + this@AppRouter.getFragmentManager() ?: return, + javaClass.fragmentTag(), + ) + } + + private fun Fragment.findFragmentByTagRecursive(fragmentTag: String): Fragment? { + childFragmentManager.findFragmentByTag(fragmentTag)?.let { + return it + } + val parent = parentFragment + return if (parent != null) { + parent.findFragmentByTagRecursive(fragmentTag) + } else { + parentFragmentManager.findFragmentByTag(fragmentTag) + } + } + + companion object { + + fun from(view: View): AppRouter? = runCatching { + AppRouter(view.findFragment()) + }.getOrElse { + (view.context.findActivity() as? FragmentActivity)?.let(::AppRouter) + } + + fun detailsIntent(context: Context, manga: Manga) = Intent(context, DetailsActivity::class.java) + .putExtra(KEY_MANGA, ParcelableManga(manga)) + + fun detailsIntent(context: Context, mangaId: Long) = Intent(context, DetailsActivity::class.java) + .putExtra(KEY_ID, mangaId) + + fun listIntent(context: Context, source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?): Intent = + Intent(context, MangaListActivity::class.java) + .setAction(ACTION_MANGA_EXPLORE) + .putExtra(KEY_SOURCE, source.name) + .apply { + if (!filter.isNullOrEmpty()) { + putExtra(KEY_FILTER, ParcelableMangaListFilter(filter)) + } + if (sortOrder != null) { + putExtra(KEY_SORT_ORDER, sortOrder) + } + } + + fun cloudFlareResolveIntent(context: Context, exception: CloudFlareProtectedException): Intent = + Intent(context, CloudFlareActivity::class.java).apply { + data = exception.url.toUri() + putExtra(KEY_SOURCE, exception.source?.name) + exception.headers[CommonHeaders.USER_AGENT]?.let { + putExtra(KEY_USER_AGENT, it) + } + } + + fun suggestionsIntent(context: Context) = Intent(context, SuggestionsActivity::class.java) + + fun homeIntent(context: Context) = Intent(context, MainActivity::class.java) + + fun mangaUpdatesIntent(context: Context) = Intent(context, UpdatesActivity::class.java) + + fun readerSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_READER) + + fun suggestionsSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_SUGGESTIONS) + + fun trackerSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_TRACKER) + + fun proxySettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_PROXY) + + fun historySettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_HISTORY) + + fun sourcesSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_SOURCES) + + fun manageSourcesIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_MANAGE_SOURCES) + + fun downloadsSettingsIntent(context: Context) = + Intent(context, SettingsActivity::class.java) + .setAction(ACTION_MANAGE_DOWNLOADS) + + fun sourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) { + is MangaSourceInfo -> sourceSettingsIntent(context, source.mangaSource) + is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) + .setData(Uri.fromParts("package", source.packageName, null)) + + else -> Intent(context, SettingsActivity::class.java) + .setAction(ACTION_SOURCE) + .putExtra(KEY_SOURCE, source.name) + } + + fun sourceAuthIntent(context: Context, source: MangaSource): Intent { + return Intent(context, SourceAuthActivity::class.java) + .putExtra(KEY_SOURCE, source.name) + } + + fun isShareSupported(manga: Manga): Boolean = when { + manga.isBroken -> false + manga.isLocal -> manga.url.toUri().toFileOrNull() != null + else -> true + } + + const val KEY_DATA = "data" + const val KEY_ENTRIES = "entries" + const val KEY_ERROR = "error" + const val KEY_EXCLUDE = "exclude" + const val KEY_FILE = "file" + const val KEY_FILTER = "filter" + const val KEY_ID = "id" + const val KEY_INDEX = "index" + const val KEY_KIND = "kind" + const val KEY_LIST_SECTION = "list_section" + const val KEY_MANGA = "manga" + const val KEY_MANGA_LIST = "manga_list" + const val KEY_PAGES = "pages" + const val KEY_QUERY = "query" + const val KEY_READER_MODE = "reader_mode" + const val KEY_SORT_ORDER = "sort_order" + const val KEY_SOURCE = "source" + const val KEY_TAB = "tab" + const val KEY_TITLE = "title" + const val KEY_URL = "url" + const val KEY_USER_AGENT = "user_agent" + + const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY" + const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS" + const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST" + const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA" + const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY" + const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" + const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" + const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES" + const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" + const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER" + + private const val ACCOUNT_KEY = "account" + private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS" + private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" + + private const val TYPE_TEXT = "text/plain" + private const val TYPE_IMAGE = "image/*" + private const val TYPE_CBZ = "application/x-cbz" + + private fun Class.fragmentTag() = name // TODO + + private inline fun fragmentTag() = F::class.java.fragmentTag() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouterEntryPoint.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouterEntryPoint.kt new file mode 100644 index 000000000..619962e5b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouterEntryPoint.kt @@ -0,0 +1,13 @@ +package org.koitharu.kotatsu.core.nav + +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.components.SingletonComponent +import org.koitharu.kotatsu.core.prefs.AppSettings + +@EntryPoint +@InstallIn(SingletonComponent::class) +interface AppRouterEntryPoint { + + val settings: AppSettings +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/MangaIntent.kt similarity index 85% rename from app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/core/nav/MangaIntent.kt index 25dd7749e..d39d56d2f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaIntent.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/MangaIntent.kt @@ -1,11 +1,12 @@ -package org.koitharu.kotatsu.core.parser +package org.koitharu.kotatsu.core.nav import android.content.Intent import android.net.Uri import android.os.Bundle import androidx.lifecycle.SavedStateHandle import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_ID +import org.koitharu.kotatsu.core.nav.AppRouter.Companion.KEY_MANGA import org.koitharu.kotatsu.core.util.ext.getParcelableCompat import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.parsers.model.Manga @@ -25,7 +26,7 @@ class MangaIntent private constructor( constructor(savedStateHandle: SavedStateHandle) : this( manga = savedStateHandle.get(KEY_MANGA)?.manga, id = savedStateHandle[KEY_ID] ?: ID_NONE, - uri = savedStateHandle[BaseActivity.EXTRA_DATA], + uri = savedStateHandle[AppRouter.KEY_DATA], ) constructor(args: Bundle?) : this( @@ -41,9 +42,6 @@ class MangaIntent private constructor( const val ID_NONE = 0L - const val KEY_MANGA = "manga" - const val KEY_ID = "id" - fun of(manga: Manga) = MangaIntent(manga, manga.id, null) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/NavUtil.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/NavUtil.kt new file mode 100644 index 000000000..1d0122d1e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/NavUtil.kt @@ -0,0 +1,39 @@ +package org.koitharu.kotatsu.core.nav + +import android.app.ActivityOptions +import android.os.Bundle +import android.view.View +import androidx.fragment.app.DialogFragment +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentActivity +import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled + +inline val FragmentActivity.router: AppRouter + get() = AppRouter(this) + +inline val Fragment.router: AppRouter + get() = AppRouter(this) + +tailrec fun Fragment.dismissParentDialog(): Boolean { + return when (val parent = parentFragment) { + null -> return false + is DialogFragment -> { + parent.dismiss() + true + } + + else -> parent.dismissParentDialog() + } +} + +fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) { + ActivityOptions.makeScaleUpAnimation( + view, + 0, + 0, + view.width, + view.height, + ).toBundle() +} else { + null +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/ReaderIntent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/ReaderIntent.kt new file mode 100644 index 000000000..d1607a55a --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/ReaderIntent.kt @@ -0,0 +1,61 @@ +package org.koitharu.kotatsu.core.nav + +import android.content.Context +import android.content.Intent +import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.bookmarks.domain.Bookmark +import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.reader.ui.ReaderState + +@JvmInline +value class ReaderIntent private constructor( + val intent: Intent, +) { + + class Builder(context: Context) { + + private val intent = Intent(context, ReaderActivity::class.java) + .setAction(ACTION_MANGA_READ) + + fun manga(manga: Manga) = apply { + intent.putExtra(AppRouter.KEY_MANGA, ParcelableManga(manga)) + } + + fun mangaId(mangaId: Long) = apply { + intent.putExtra(AppRouter.KEY_ID, mangaId) + } + + fun incognito(incognito: Boolean) = apply { + intent.putExtra(EXTRA_INCOGNITO, incognito) + } + + fun branch(branch: String?) = apply { + intent.putExtra(EXTRA_BRANCH, branch) + } + + fun state(state: ReaderState?) = apply { + intent.putExtra(EXTRA_STATE, state) + } + + fun bookmark(bookmark: Bookmark) = manga( + bookmark.manga, + ).state( + ReaderState( + chapterId = bookmark.chapterId, + page = bookmark.page, + scroll = bookmark.scroll, + ), + ) + + fun build() = ReaderIntent(intent) + } + + companion object { + const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA" + const val EXTRA_STATE = "state" + const val EXTRA_BRANCH = "branch" + const val EXTRA_INCOGNITO = "incognito" + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt deleted file mode 100644 index 4ac33ad07..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/AppProxySelector.kt +++ /dev/null @@ -1,51 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import okio.IOException -import org.koitharu.kotatsu.core.exceptions.ProxyConfigException -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import java.net.InetSocketAddress -import java.net.Proxy -import java.net.ProxySelector -import java.net.SocketAddress -import java.net.URI - -class AppProxySelector( - private val settings: AppSettings, -) : ProxySelector() { - - init { - setDefault(this) - } - - private var cachedProxy: Proxy? = null - - override fun select(uri: URI?): List { - return listOf(getProxy()) - } - - override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: IOException?) { - ioe?.printStackTraceDebug() - } - - private fun getProxy(): Proxy { - val type = settings.proxyType - val address = settings.proxyAddress - val port = settings.proxyPort - if (type == Proxy.Type.DIRECT) { - return Proxy.NO_PROXY - } - if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) { - throw ProxyConfigException() - } - cachedProxy?.let { - val addr = it.address() as? InetSocketAddress - if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) { - return it - } - } - val proxy = Proxy(type, InetSocketAddress(address, port)) - cachedProxy = proxy - return proxy - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt index 82d0148d5..2fc0a1204 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/CommonHeadersInterceptor.kt @@ -65,7 +65,7 @@ class CommonHeadersInterceptor @Inject constructor( private fun Interceptor.interceptSafe(chain: Chain): Response = runCatchingCancellable { intercept(chain) }.getOrElse { e -> - if (e is IOException) { + if (e is IOException || e is Error) { throw e } else { // only IOException can be safely thrown from an Interceptor diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt index 0200b99c4..7711545ea 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/NetworkModule.kt @@ -16,6 +16,7 @@ import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.network.cookies.PreferencesCookieJar import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.network.imageproxy.RealImageProxyInterceptor +import org.koitharu.kotatsu.core.network.proxy.ProxyProvider import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.assertNotInMainThread import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug @@ -62,14 +63,15 @@ interface NetworkModule { cache: Cache, cookieJar: CookieJar, settings: AppSettings, + proxyProvider: ProxyProvider, ): OkHttpClient = OkHttpClient.Builder().apply { assertNotInMainThread() connectTimeout(20, TimeUnit.SECONDS) readTimeout(60, TimeUnit.SECONDS) writeTimeout(20, TimeUnit.SECONDS) cookieJar(cookieJar) - proxySelector(AppProxySelector(settings)) - proxyAuthenticator(ProxyAuthenticator(settings)) + proxySelector(proxyProvider.selector) + proxyAuthenticator(proxyProvider.authenticator) dns(DoHManager(cache, settings)) if (settings.isSSLBypassEnabled) { disableCertificateVerification() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ProxyAuthenticator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ProxyAuthenticator.kt deleted file mode 100644 index fb4ffad7e..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/ProxyAuthenticator.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.koitharu.kotatsu.core.network - -import okhttp3.Authenticator -import okhttp3.Credentials -import okhttp3.Request -import okhttp3.Response -import okhttp3.Route -import org.koitharu.kotatsu.core.prefs.AppSettings -import java.net.PasswordAuthentication -import java.net.Proxy - -class ProxyAuthenticator( - private val settings: AppSettings, -) : Authenticator, java.net.Authenticator() { - - init { - setDefault(this) - } - - override fun authenticate(route: Route?, response: Response): Request? { - if (!isProxyEnabled()) { - return null - } - if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) { - return null - } - val login = settings.proxyLogin ?: return null - val password = settings.proxyPassword ?: return null - val credential = Credentials.basic(login, password) - return response.request.newBuilder() - .header(CommonHeaders.PROXY_AUTHORIZATION, credential) - .build() - } - - override fun getPasswordAuthentication(): PasswordAuthentication? { - if (!isProxyEnabled()) { - return null - } - val login = settings.proxyLogin ?: return null - val password = settings.proxyPassword ?: return null - return PasswordAuthentication(login, password.toCharArray()) - } - - private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/network/proxy/ProxyProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/proxy/ProxyProvider.kt new file mode 100644 index 000000000..7b6df3c5e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/network/proxy/ProxyProvider.kt @@ -0,0 +1,150 @@ +package org.koitharu.kotatsu.core.network.proxy + +import androidx.webkit.ProxyConfig +import androidx.webkit.ProxyController +import androidx.webkit.WebViewFeature +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.asExecutor +import okhttp3.Authenticator +import okhttp3.Credentials +import okhttp3.Request +import okhttp3.Response +import okhttp3.Route +import org.koitharu.kotatsu.core.exceptions.ProxyConfigException +import org.koitharu.kotatsu.core.network.CommonHeaders +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import java.net.InetSocketAddress +import java.net.PasswordAuthentication +import java.net.Proxy +import java.net.ProxySelector +import java.net.SocketAddress +import java.net.URI +import javax.inject.Inject +import javax.inject.Singleton +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine +import java.net.Authenticator as JavaAuthenticator + +@Singleton +class ProxyProvider @Inject constructor( + private val settings: AppSettings, +) { + + private var cachedProxy: Proxy? = null + + val selector = object : ProxySelector() { + override fun select(uri: URI?): List { + return listOf(getProxy()) + } + + override fun connectFailed(uri: URI?, sa: SocketAddress?, ioe: okio.IOException?) { + ioe?.printStackTraceDebug() + } + } + + val authenticator = ProxyAuthenticator() + + init { + ProxySelector.setDefault(selector) + JavaAuthenticator.setDefault(authenticator) + } + + suspend fun applyWebViewConfig() { + val isProxyEnabled = isProxyEnabled() + if (!WebViewFeature.isFeatureSupported(WebViewFeature.PROXY_OVERRIDE)) { + if (isProxyEnabled) { + throw IllegalArgumentException("Proxy for WebView is not supported") // TODO localize + } + } else { + val controller = ProxyController.getInstance() + if (settings.proxyType == Proxy.Type.DIRECT) { + suspendCoroutine { cont -> + controller.clearProxyOverride( + (cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(), + ) { + cont.resume(Unit) + } + } + } else { + val url = buildString { + when (settings.proxyType) { + Proxy.Type.DIRECT -> Unit + Proxy.Type.HTTP -> append("http") + Proxy.Type.SOCKS -> append("socks") + } + append("://") + append(settings.proxyAddress) + append(':') + append(settings.proxyPort) + } + if (settings.proxyType == Proxy.Type.SOCKS) { + System.setProperty("java.net.socks.username", settings.proxyLogin); + System.setProperty("java.net.socks.password", settings.proxyPassword); + } + val proxyConfig = ProxyConfig.Builder() + .addProxyRule(url) + .build() + suspendCoroutine { cont -> + controller.setProxyOverride( + proxyConfig, + (cont.context[CoroutineDispatcher] ?: Dispatchers.Main).asExecutor(), + ) { + cont.resume(Unit) + } + } + } + } + } + + private fun isProxyEnabled() = settings.proxyType != Proxy.Type.DIRECT + + private fun getProxy(): Proxy { + val type = settings.proxyType + val address = settings.proxyAddress + val port = settings.proxyPort + if (type == Proxy.Type.DIRECT) { + return Proxy.NO_PROXY + } + if (address.isNullOrEmpty() || port < 0 || port > 0xFFFF) { + throw ProxyConfigException() + } + cachedProxy?.let { + val addr = it.address() as? InetSocketAddress + if (addr != null && it.type() == type && addr.port == port && addr.hostString == address) { + return it + } + } + val proxy = Proxy(type, InetSocketAddress(address, port)) + cachedProxy = proxy + return proxy + } + + inner class ProxyAuthenticator : Authenticator, JavaAuthenticator() { + + override fun authenticate(route: Route?, response: Response): Request? { + if (!isProxyEnabled()) { + return null + } + if (response.request.header(CommonHeaders.PROXY_AUTHORIZATION) != null) { + return null + } + val login = settings.proxyLogin ?: return null + val password = settings.proxyPassword ?: return null + val credential = Credentials.basic(login, password) + return response.request.newBuilder() + .header(CommonHeaders.PROXY_AUTHORIZATION, credential) + .build() + } + + public override fun getPasswordAuthentication(): PasswordAuthentication? { + if (!isProxyEnabled()) { + return null + } + val login = settings.proxyLogin ?: return null + val password = settings.proxyPassword ?: return null + return PasswordAuthentication(login, password.toCharArray()) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt index e406d8e55..df7e2ba57 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt @@ -23,6 +23,8 @@ import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.db.TABLE_HISTORY import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.prefs.AppSettings @@ -36,8 +38,6 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.mapNotNullToSet import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.reader.ui.ReaderActivity -import org.koitharu.kotatsu.search.ui.MangaListActivity import javax.inject.Inject import javax.inject.Singleton @@ -133,7 +133,7 @@ class AppShortcutManager @Inject constructor( } } - private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat { + private suspend fun buildShortcutInfo(manga: Manga): ShortcutInfoCompat = withContext(Dispatchers.Default) { val icon = runCatchingCancellable { coil.execute( ImageRequest.Builder(context) @@ -149,17 +149,17 @@ class AppShortcutManager @Inject constructor( onFailure = { IconCompat.createWithResource(context, R.drawable.ic_shortcut_default) }, ) mangaRepository.storeManga(manga) - return ShortcutInfoCompat.Builder(context, manga.id.toString()) + ShortcutInfoCompat.Builder(context, manga.id.toString()) .setShortLabel(manga.title) .setLongLabel(manga.title) .setIcon(icon) .setLongLived(true) .setIntent( - ReaderActivity.IntentBuilder(context) + ReaderIntent.Builder(context) .mangaId(manga.id) - .build(), - ) - .build() + .build() + .intent, + ).build() } private suspend fun buildShortcutInfo(source: MangaSource): ShortcutInfoCompat = withContext(Dispatchers.Default) { @@ -181,7 +181,7 @@ class AppShortcutManager @Inject constructor( .setLongLabel(title) .setIcon(icon) .setLongLived(true) - .setIntent(MangaListActivity.newIntent(context, source, null)) + .setIntent(AppRouter.listIntent(context, source, null, null)) .build() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppValidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppValidator.kt index e95207ca3..897420126 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppValidator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppValidator.kt @@ -1,9 +1,12 @@ package org.koitharu.kotatsu.core.os +import android.annotation.SuppressLint import android.content.Context import android.content.pm.PackageManager import androidx.core.content.pm.PackageInfoCompat import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import javax.inject.Inject import javax.inject.Singleton @@ -11,8 +14,8 @@ import javax.inject.Singleton class AppValidator @Inject constructor( @ApplicationContext private val context: Context, ) { - @Suppress("NewApi") - val isOriginalApp by lazy { + @SuppressLint("InlinedApi") + val isOriginalApp = suspendLazy(Dispatchers.Default) { val certificates = mapOf(CERT_SHA256.hexToByteArray() to PackageManager.CERT_INPUT_SHA256) PackageInfoCompat.hasSignatures(context.packageManager, context.packageName, certificates, false) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt index 0d98d2368..c05ec4593 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/NetworkState.kt @@ -69,7 +69,7 @@ class NetworkState( return true } return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { - activeNetwork?.let { isOnline(it) } ?: false + activeNetwork?.let { isOnline(it) } == true } else { @Suppress("DEPRECATION") activeNetworkInfo?.isConnected == true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/OpenDocumentTreeHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/OpenDocumentTreeHelper.kt new file mode 100644 index 000000000..8a9dc368e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/OpenDocumentTreeHelper.kt @@ -0,0 +1,92 @@ +package org.koitharu.kotatsu.core.os + +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.os.storage.StorageManager +import android.provider.DocumentsContract +import androidx.activity.result.ActivityResultCallback +import androidx.activity.result.ActivityResultCaller +import androidx.activity.result.ActivityResultLauncher +import androidx.activity.result.contract.ActivityResultContract +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.RequiresApi +import androidx.core.app.ActivityOptionsCompat +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug + +// https://stackoverflow.com/questions/77555641/saf-no-activity-found-to-handle-intent-android-intent-action-open-document-tr +class OpenDocumentTreeHelper( + activityResultCaller: ActivityResultCaller, + flags: Int, + callback: ActivityResultCallback +) : ActivityResultLauncher() { + + constructor(activityResultCaller: ActivityResultCaller, callback: ActivityResultCallback) : this( + activityResultCaller, + 0, + callback, + ) + + private val pickFileTreeLauncherQ = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + activityResultCaller.registerForActivityResult(OpenDocumentTreeContractQ(flags), callback) + } else { + null + } + private val pickFileTreeLauncherLegacy = activityResultCaller.registerForActivityResult( + contract = OpenDocumentTreeContractLegacy(flags), + callback = callback, + ) + + override fun launch(input: Uri?, options: ActivityOptionsCompat?) { + if (pickFileTreeLauncherQ == null) { + pickFileTreeLauncherLegacy.launch(input, options) + return + } + try { + pickFileTreeLauncherQ.launch(input, options) + } catch (e: Exception) { + e.printStackTraceDebug() + pickFileTreeLauncherLegacy.launch(input, options) + } + } + + override fun unregister() { + pickFileTreeLauncherQ?.unregister() + pickFileTreeLauncherLegacy.unregister() + } + + override val contract: ActivityResultContract + get() = pickFileTreeLauncherQ?.contract ?: pickFileTreeLauncherLegacy.contract + + private open class OpenDocumentTreeContractLegacy( + private val flags: Int, + ) : ActivityResultContracts.OpenDocumentTree() { + + override fun createIntent(context: Context, input: Uri?): Intent { + val intent = super.createIntent(context, input) + intent.addFlags(flags) + return intent + } + } + + @RequiresApi(Build.VERSION_CODES.Q) + private class OpenDocumentTreeContractQ( + private val flags: Int, + ) : OpenDocumentTreeContractLegacy(flags) { + + override fun createIntent(context: Context, input: Uri?): Intent { + val intent = (context.getSystemService(Context.STORAGE_SERVICE) as? StorageManager) + ?.primaryStorageVolume + ?.createOpenDocumentTreeIntent() + if (intent == null) { // fallback + return super.createIntent(context, input) + } + intent.addFlags(flags) + if (input != null) { + intent.putExtra(DocumentsContract.EXTRA_INITIAL_URI, input) + } + return intent + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/os/RomCompat.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/RomCompat.kt new file mode 100644 index 000000000..ec519c179 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/RomCompat.kt @@ -0,0 +1,18 @@ +package org.koitharu.kotatsu.core.os + +import kotlinx.coroutines.Dispatchers +import org.jetbrains.annotations.Blocking +import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy +import java.io.InputStreamReader + +object RomCompat { + + val isMiui = suspendLazy(Dispatchers.IO) { + getProp("ro.miui.ui.version.name").isNotEmpty() + } + + @Blocking + private fun getProp(propName: String) = Runtime.getRuntime().exec("getprop $propName").inputStream.use { + it.reader().use(InputStreamReader::readText).trim() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt index 74371571c..fd21f811a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/DummyParser.kt @@ -2,22 +2,22 @@ package org.koitharu.kotatsu.core.parser import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.parsers.MangaLoaderContext -import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.config.ConfigKey +import org.koitharu.kotatsu.parsers.core.AbstractMangaParser import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.MangaListFilterCapabilities import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQuery +import org.koitharu.kotatsu.parsers.model.search.MangaSearchQueryCapabilities import java.util.EnumSet /** * This parser is just for parser development, it should not be used in releases */ -class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParserSource.DUMMY) { +class DummyParser(context: MangaLoaderContext) : AbstractMangaParser(context, MangaParserSource.DUMMY) { override val configKeyDomain: ConfigKey.Domain get() = ConfigKey.Domain("localhost") @@ -25,14 +25,14 @@ class DummyParser(context: MangaLoaderContext) : MangaParser(context, MangaParse override val availableSortOrders: Set get() = EnumSet.allOf(SortOrder::class.java) - override val filterCapabilities: MangaListFilterCapabilities - get() = MangaListFilterCapabilities() - - override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null) + override val searchQueryCapabilities: MangaSearchQueryCapabilities + get() = MangaSearchQueryCapabilities() override suspend fun getDetails(manga: Manga): Manga = stub(manga) - override suspend fun getList(offset: Int, order: SortOrder, filter: MangaListFilter): List = stub(null) + override suspend fun getFilterOptions(): MangaListFilterOptions = stub(null) + + override suspend fun getList(query: MangaSearchQuery): List = stub(null) override suspend fun getPages(chapter: MangaChapter): List = stub(null) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt index 25be07122..521b2536f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaDataRepository.kt @@ -14,6 +14,8 @@ import org.koitharu.kotatsu.core.db.entity.toManga import org.koitharu.kotatsu.core.db.entity.toMangaTags import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.nav.MangaIntent +import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.util.ext.toFileOrNull import org.koitharu.kotatsu.parsers.model.Manga @@ -27,6 +29,7 @@ import javax.inject.Provider class MangaDataRepository @Inject constructor( private val db: MangaDatabase, private val resolverProvider: Provider, + private val appShortcutManagerProvider: Provider, ) { suspend fun saveReaderMode(manga: Manga, mode: ReaderMode) { @@ -45,8 +48,8 @@ class MangaDataRepository @Inject constructor( entity.copy( cfBrightness = colorFilter?.brightness ?: 0f, cfContrast = colorFilter?.contrast ?: 0f, - cfInvert = colorFilter?.isInverted ?: false, - cfGrayscale = colorFilter?.isGrayscale ?: false, + cfInvert = colorFilter?.isInverted == true, + cfGrayscale = colorFilter?.isGrayscale == true, ), ) } @@ -70,8 +73,13 @@ class MangaDataRepository @Inject constructor( .distinctUntilChanged() } - suspend fun findMangaById(mangaId: Long): Manga? { - return db.getMangaDao().find(mangaId)?.toManga() + suspend fun findMangaById(mangaId: Long, withChapters: Boolean): Manga? { + val chapters = if (withChapters) { + db.getChaptersDao().findAll(mangaId).takeUnless { it.isEmpty() } + } else { + null + } + return db.getMangaDao().find(mangaId)?.toManga(chapters) } suspend fun findMangaByPublicUrl(publicUrl: String): Manga? { @@ -80,7 +88,7 @@ class MangaDataRepository @Inject constructor( suspend fun resolveIntent(intent: MangaIntent): Manga? = when { intent.manga != null -> intent.manga - intent.mangaId != 0L -> findMangaById(intent.mangaId) + intent.mangaId != 0L -> findMangaById(intent.mangaId, true) intent.uri != null -> resolverProvider.get().resolve(intent.uri) else -> null } @@ -97,10 +105,26 @@ class MangaDataRepository @Inject constructor( val tags = manga.tags.toEntities() db.getTagsDao().upsert(tags) db.getMangaDao().upsert(manga.toEntity(), tags) + if (!manga.isLocal) { + manga.chapters?.let { chapters -> + db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id)) + } + } } } } + suspend fun updateChapters(manga: Manga) { + val chapters = manga.chapters + if (!chapters.isNullOrEmpty() && manga.id in db.getMangaDao()) { + db.getChaptersDao().replaceAll(manga.id, chapters.withIndex().toEntities(manga.id)) + } + } + + suspend fun gcChaptersCache() { + db.getChaptersDao().gc() + } + suspend fun findTags(source: MangaSource): Set { return db.getTagsDao().findTags(source.name).toMangaTags() } @@ -114,6 +138,14 @@ class MangaDataRepository @Inject constructor( } } + suspend fun cleanupDatabase() { + db.withTransaction { + gcChaptersCache() + val idsFromShortcuts = appShortcutManagerProvider.get().getMangaShortcuts() + db.getMangaDao().cleanup(idsFromShortcuts) + } + } + private fun MangaPrefsEntity.getColorFilterOrNull(): ReaderColorFilter? { return if (cfBrightness != 0f || cfContrast != 0f || cfInvert || cfGrayscale) { ReaderColorFilter(cfBrightness, cfContrast, cfInvert, cfGrayscale) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt index 7a94fcf2f..72fe0ba59 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLinkResolver.kt @@ -6,6 +6,7 @@ import dagger.Reusable import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.exception.NotFoundException import org.koitharu.kotatsu.parsers.model.Manga @@ -109,4 +110,11 @@ class MangaLinkResolver @Inject constructor( chapters = null, source = source, ) + + companion object { + + fun isValidLink(str: String): Boolean { + return str.isHttpUrl() || str.startsWith("kotatsu://", ignoreCase = true) + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt index c68c79fec..9d07a1c47 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/MangaLoaderContextImpl.kt @@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.configureForParser import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.sanitizeHeaderValue import org.koitharu.kotatsu.core.util.ext.toList +import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.parsers.MangaLoaderContext import org.koitharu.kotatsu.parsers.bitmap.Bitmap @@ -78,13 +79,14 @@ class MangaLoaderContextImpl @Inject constructor( override fun redrawImageResponse(response: Response, redraw: (image: Bitmap) -> Bitmap): Response { return response.map { body -> - BitmapDecoderCompat.decode(body.byteStream(), body.contentType(), isMutable = true).use { bitmap -> - (redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result -> - Buffer().also { - result.compressTo(it.outputStream()) - }.asResponseBody("image/jpeg".toMediaType()) + BitmapDecoderCompat.decode(body.byteStream(), body.contentType()?.toMimeType(), isMutable = true) + .use { bitmap -> + (redraw(BitmapWrapper.create(bitmap)) as BitmapWrapper).use { result -> + Buffer().also { + result.compressTo(it.outputStream()) + }.asResponseBody("image/jpeg".toMediaType()) + } } - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt index 3b0e99738..67a9d1967 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/ParserMangaRepository.kt @@ -18,7 +18,6 @@ import org.koitharu.kotatsu.parsers.model.MangaListFilterOptions import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.SortOrder -import org.koitharu.kotatsu.parsers.util.domain import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy @@ -58,13 +57,7 @@ class ParserMangaRepository( val domains: Array get() = parser.configKeyDomain.presetValues - override fun intercept(chain: Interceptor.Chain): Response { - return if (parser is Interceptor) { - parser.intercept(chain) - } else { - chain.proceed(chain.request()) - } - } + override fun intercept(chain: Interceptor.Chain): Response = parser.intercept(chain) override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List { return mirrorSwitchInterceptor.withMirrorSwitching { @@ -96,7 +89,7 @@ class ParserMangaRepository( parser.getDetails(manga) } - fun getAuthProvider(): MangaParserAuthProvider? = parser as? MangaParserAuthProvider + fun getAuthProvider(): MangaParserAuthProvider? = parser.authorizationProvider fun getRequestHeaders() = parser.getRequestHeaders() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt index 999725e70..80ad1df5d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalMangaRepository.kt @@ -42,7 +42,7 @@ class ExternalMangaRepository( override var defaultSortOrder: SortOrder get() = capabilities?.availableSortOrders?.firstOrNull() ?: SortOrder.ALPHABETICAL - set(_) = Unit + set(value) = Unit override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptions.get() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt index 743a96401..e81192dca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginContentSource.kt @@ -229,7 +229,7 @@ class ExternalPluginContentSource( do { result += MangaChapter( id = cursor.getLong(COLUMN_ID), - name = cursor.getString(COLUMN_NAME), + title = cursor.getStringOrNull(COLUMN_NAME), number = cursor.getFloatOrDefault(COLUMN_NUMBER, 0f), volume = cursor.getIntOrDefault(COLUMN_VOLUME, 0), url = cursor.getString(COLUMN_URL), @@ -252,7 +252,7 @@ class ExternalPluginContentSource( publicUrl = getString(COLUMN_PUBLIC_URL), rating = getFloat(COLUMN_RATING), isNsfw = getBooleanOrDefault(COLUMN_IS_NSFW, false), - coverUrl = getString(COLUMN_COVER_URL), + coverUrl = getStringOrNull(COLUMN_COVER_URL), tags = getStringOrNull(COLUMN_TAGS)?.split(':')?.mapNotNullToSet { val parts = it.splitTwoParts('=') ?: return@mapNotNullToSet null MangaTag(key = parts.first, title = parts.second, source = source) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginCursor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginCursor.kt index 33cab9511..807ea5b4b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginCursor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/parser/external/ExternalPluginCursor.kt @@ -20,7 +20,7 @@ class ExternalPluginCursor(private val source: ExternalMangaSource, cursor: Curs return when { columnIndex < 0 -> null isNull(columnIndex) -> null - else -> getString(columnIndex) + else -> getString(columnIndex).takeUnless { it == "null" } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt index 25b220cae..85d73cee6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt @@ -15,12 +15,13 @@ import androidx.core.os.LocaleListCompat import androidx.documentfile.provider.DocumentFile import androidx.preference.PreferenceManager import dagger.hilt.android.qualifiers.ApplicationContext -import org.json.JSONArray +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.network.DoHProvider import org.koitharu.kotatsu.core.util.ext.connectivityManager import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.putAll import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.toUriOrNull @@ -44,6 +45,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { private val prefs = PreferenceManager.getDefaultSharedPreferences(context) private val connectivityManager = context.connectivityManager + private val mangaListBadgesDefault = ArraySet(context.resources.getStringArray(R.array.values_list_badges)) var listMode: ListMode get() = prefs.getEnumValue(KEY_LIST_MODE, ListMode.GRID) @@ -141,6 +143,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isReaderOptimizationEnabled: Boolean get() = prefs.getBoolean(KEY_READER_OPTIMIZE, false) + val readerControls: Set + get() = prefs.getStringSet(KEY_READER_CONTROLS, null)?.mapNotNullTo(EnumSet.noneOf(ReaderControl::class.java)) { + ReaderControl.entries.find(it) + } ?: ReaderControl.DEFAULT + val isOfflineCheckDisabled: Boolean get() = prefs.getBoolean(KEY_OFFLINE_DISABLED, false) @@ -299,6 +306,10 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getInt(KEY_SOURCES_VERSION, 0) set(value) = prefs.edit { putInt(KEY_SOURCES_VERSION, value) } + var isAllSourcesEnabled: Boolean + get() = prefs.getBoolean(KEY_SOURCES_ENABLED_ALL, false) + set(value) = prefs.edit { putBoolean(KEY_SOURCES_ENABLED_ALL, value) } + val isPagesNumbersEnabled: Boolean get() = prefs.getBoolean(KEY_PAGES_NUMBERS, false) @@ -363,8 +374,8 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { val isReaderBarEnabled: Boolean get() = prefs.getBoolean(KEY_READER_BAR, true) - val isReaderSliderEnabled: Boolean - get() = prefs.getBoolean(KEY_READER_SLIDER, true) + val isReaderBarTransparent: Boolean + get() = prefs.getBoolean(KEY_READER_BAR_TRANSPARENT, true) val isReaderKeepScreenOn: Boolean get() = prefs.getBoolean(KEY_READER_SCREEN_ON, true) @@ -489,6 +500,12 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { get() = prefs.getString(KEY_BACKUP_PERIODICAL_OUTPUT, null)?.toUriOrNull() set(value) = prefs.edit { putString(KEY_BACKUP_PERIODICAL_OUTPUT, value?.toString()) } + val isBackupTelegramUploadEnabled: Boolean + get() = prefs.getBoolean(KEY_BACKUP_TG_ENABLED, false) + + val backupTelegramChatId: String? + get() = prefs.getString(KEY_BACKUP_TG_CHAT, null)?.nullIfEmpty() + val isReadingTimeEstimationEnabled: Boolean get() = prefs.getBoolean(KEY_READING_TIME, true) @@ -531,6 +548,15 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { prefs.edit { putString(KEY_PAGES_SAVE_DIR, uri?.toString()) } } + fun getMangaListBadges(): Int { + val raw = prefs.getStringSet(KEY_MANGA_LIST_BADGES, mangaListBadgesDefault).orEmpty() + var result = 0 + for (item in raw) { + result = result or item.toInt() + } + return result + } + fun subscribe(listener: SharedPreferences.OnSharedPreferenceChangeListener) { prefs.registerOnSharedPreferenceChangeListener(listener) } @@ -543,19 +569,9 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { fun getAllValues(): Map = prefs.all - fun upsertAll(m: Map) { - prefs.edit { - m.forEach { e -> - when (val v = e.value) { - is Boolean -> putBoolean(e.key, v) - is Int -> putInt(e.key, v) - is Long -> putLong(e.key, v) - is Float -> putFloat(e.key, v) - is String -> putString(e.key, v) - is JSONArray -> putStringSet(e.key, v.toStringSet()) - } - } - } + fun upsertAll(m: Map) = prefs.edit { + clear() + putAll(m) } private fun isBackgroundNetworkRestricted(): Boolean { @@ -566,15 +582,6 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { } } - private fun JSONArray.toStringSet(): Set { - val len = length() - val result = ArraySet(len) - for (i in 0 until len) { - result.add(getString(i)) - } - return result - } - companion object { const val TRACK_HISTORY = "history" @@ -621,6 +628,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_NOTIFICATIONS_LIGHT = "notifications_light" const val KEY_NOTIFICATIONS_INFO = "tracker_notifications_info" const val KEY_READER_ANIMATION = "reader_animation2" + const val KEY_READER_CONTROLS = "reader_controls" const val KEY_READER_MODE = "reader_mode" const val KEY_READER_MODE_DETECT = "reader_mode_detect" const val KEY_READER_CROP = "reader_crop" @@ -664,7 +672,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_SYNC = "sync" const val KEY_SYNC_SETTINGS = "sync_settings" const val KEY_READER_BAR = "reader_bar" - const val KEY_READER_SLIDER = "reader_slider" + const val KEY_READER_BAR_TRANSPARENT = "reader_bar_transparent" const val KEY_READER_BACKGROUND = "reader_background" const val KEY_READER_SCREEN_ON = "reader_screen_on" const val KEY_SHORTCUTS = "dynamic_shortcuts" @@ -715,13 +723,16 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_FEED_HEADER = "feed_header" const val KEY_SEARCH_SUGGESTION_TYPES = "search_suggest_types" const val KEY_SOURCES_VERSION = "sources_version" + const val KEY_SOURCES_ENABLED_ALL = "sources_enabled_all" const val KEY_QUICK_FILTER = "quick_filter" + const val KEY_BACKUP_TG_ENABLED = "backup_periodic_tg_enabled" + const val KEY_BACKUP_TG_CHAT = "backup_periodic_tg_chat_id" + const val KEY_MANGA_LIST_BADGES = "manga_list_badges" // keys for non-persistent preferences const val KEY_APP_VERSION = "app_version" const val KEY_IGNORE_DOZE = "ignore_dose" const val KEY_TRACKER_DEBUG = "tracker_debug" - const val KEY_APP_UPDATE = "app_update" const val KEY_LINK_WEBLATE = "about_app_translation" const val KEY_LINK_TELEGRAM = "about_telegram" const val KEY_LINK_GITHUB = "about_github" @@ -729,6 +740,11 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) { const val KEY_PROXY_TEST = "proxy_test" const val KEY_OPEN_BROWSER = "open_browser" const val KEY_HANDLE_LINKS = "handle_links" + const val KEY_BACKUP_TG_OPEN = "backup_periodic_tg_open" + const val KEY_BACKUP_TG_TEST = "backup_periodic_tg_test" + const val KEY_CLEAR_MANGA_DATA = "manga_data_clear" + const val KEY_STORAGE_USAGE = "storage_usage" + const val KEY_WEBVIEW_CLEAR = "webview_clear" // old keys are for migration only private const val KEY_IMAGES_PROXY_OLD = "images_proxy" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt index 0f4d7da7e..e24d372fb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderBackground.kt @@ -1,11 +1,13 @@ package org.koitharu.kotatsu.core.prefs import android.content.Context +import android.graphics.drawable.Drawable import android.view.ContextThemeWrapper import androidx.annotation.Keep import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.toDrawable import org.koitharu.kotatsu.core.util.ext.getThemeDrawable +import org.koitharu.kotatsu.core.util.ext.isNightMode import com.google.android.material.R as materialR @Keep @@ -13,7 +15,7 @@ enum class ReaderBackground { DEFAULT, LIGHT, DARK, WHITE, BLACK; - fun resolve(context: Context) = when (this) { + fun resolve(context: Context): Drawable? = when (this) { DEFAULT -> context.getThemeDrawable(android.R.attr.windowBackground) LIGHT -> ContextThemeWrapper(context, materialR.style.ThemeOverlay_Material3_Light) .getThemeDrawable(android.R.attr.windowBackground) @@ -24,4 +26,14 @@ enum class ReaderBackground { WHITE -> ContextCompat.getColor(context, android.R.color.white).toDrawable() BLACK -> ContextCompat.getColor(context, android.R.color.black).toDrawable() } + + fun isLight(context: Context): Boolean = when (this) { + DEFAULT -> !context.resources.isNightMode + + LIGHT, + WHITE -> true + + DARK, + BLACK -> false + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderControl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderControl.kt new file mode 100644 index 000000000..3465f6cec --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/ReaderControl.kt @@ -0,0 +1,15 @@ +package org.koitharu.kotatsu.core.prefs + +import java.util.EnumSet + +enum class ReaderControl { + + PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET, SCREEN_ROTATION, SAVE_PAGE; + + companion object { + + val DEFAULT: Set = EnumSet.of( + PREV_CHAPTER, NEXT_CHAPTER, SLIDER, PAGES_SHEET, + ) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt index 6e3425ffa..ab620658d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/SourceSettings.kt @@ -25,6 +25,9 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig val isSlowdownEnabled: Boolean get() = prefs.getBoolean(KEY_SLOWDOWN, false) + val isCaptchaNotificationsDisabled: Boolean + get() = prefs.getBoolean(KEY_NO_CAPTCHA, false) + @Suppress("UNCHECKED_CAST") override fun get(key: ConfigKey): T { return when (key) { @@ -65,5 +68,6 @@ class SourceSettings(context: Context, source: MangaSource) : MangaSourceConfig const val KEY_SORT_ORDER = "sort_order" const val KEY_SLOWDOWN = "slowdown" + const val KEY_NO_CAPTCHA = "no_captcha" } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt index f9c89b131..883afdc0b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt @@ -1,18 +1,22 @@ package org.koitharu.kotatsu.core.ui +import android.content.Context import android.content.Intent import android.content.res.Configuration -import android.graphics.Color +import android.os.Build import android.os.Bundle import android.view.KeyEvent import android.view.View import android.widget.Toast +import androidx.activity.enableEdgeToEdge import androidx.annotation.CallSuper import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.view.ActionMode import androidx.appcompat.widget.Toolbar import androidx.core.app.ActivityCompat -import androidx.core.view.WindowCompat +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat import androidx.fragment.app.FragmentManager import androidx.viewbinding.ViewBinding import dagger.hilt.android.EntryPointAccessors @@ -21,16 +25,17 @@ import kotlinx.coroutines.flow.flowOf import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate -import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate import org.koitharu.kotatsu.core.util.ext.isWebViewUnavailable import org.koitharu.kotatsu.main.ui.protect.ScreenshotPolicyHelper +import com.google.android.material.R as materialR abstract class BaseActivity : AppCompatActivity(), ExceptionResolver.Host, - ScreenshotPolicyHelper.ContentContainer, - WindowInsetsDelegate.WindowInsetsListener { + OnApplyWindowInsetsListener, + ScreenshotPolicyHelper.ContentContainer { private var isAmoledTheme = false @@ -40,16 +45,20 @@ abstract class BaseActivity : protected lateinit var exceptionResolver: ExceptionResolver private set - @JvmField - protected val insetsDelegate = WindowInsetsDelegate() - @JvmField val actionModeDelegate = ActionModeDelegate() - private var defaultStatusBarColor = Color.TRANSPARENT + private lateinit var entryPoint: BaseActivityEntryPoint + + override fun attachBaseContext(newBase: Context) { + entryPoint = EntryPointAccessors.fromApplication(newBase.applicationContext) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.TIRAMISU) { + AppCompatDelegate.setApplicationLocales(entryPoint.settings.appLocales) + } + super.attachBaseContext(newBase) + } override fun onCreate(savedInstanceState: Bundle?) { - val entryPoint = EntryPointAccessors.fromApplication(this) val settings = entryPoint.settings isAmoledTheme = settings.isAmoledTheme setTheme(settings.colorScheme.styleResId) @@ -58,10 +67,8 @@ abstract class BaseActivity : } putDataToExtras(intent) exceptionResolver = entryPoint.exceptionResolverFactory.create(this) + enableEdgeToEdge() super.onCreate(savedInstanceState) - WindowCompat.setDecorFitsSystemWindows(window, false) - insetsDelegate.handleImeInsets = true - insetsDelegate.addInsetsListener(this) } override fun onPostCreate(savedInstanceState: Bundle?) { @@ -75,16 +82,10 @@ abstract class BaseActivity : } @Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR) - override fun setContentView(layoutResID: Int) { - super.setContentView(layoutResID) - setupToolbar() - } + override fun setContentView(layoutResID: Int) = throw UnsupportedOperationException() @Deprecated("Use ViewBinding", level = DeprecationLevel.ERROR) - override fun setContentView(view: View?) { - super.setContentView(view) - setupToolbar() - } + override fun setContentView(view: View?) = throw UnsupportedOperationException() override fun getContext() = this @@ -93,9 +94,18 @@ abstract class BaseActivity : protected fun setContentView(binding: B) { this.viewBinding = binding super.setContentView(binding.root) + ViewCompat.setOnApplyWindowInsetsListener(binding.root, this) val toolbar = (binding.root.findViewById(R.id.toolbar) as? Toolbar) toolbar?.let(this::setSupportActionBar) - insetsDelegate.onViewCreated(binding.root) + } + + protected fun setDisplayHomeAsUp(isEnabled: Boolean, showUpAsClose: Boolean) { + supportActionBar?.run { + setDisplayHomeAsUpEnabled(isEnabled) + if (showUpAsClose) { + setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) + } + } } override fun onSupportNavigateUp(): Boolean { @@ -123,10 +133,6 @@ abstract class BaseActivity : return super.onKeyDown(keyCode, event) } - private fun setupToolbar() { - (findViewById(R.id.toolbar) as? Toolbar)?.let(this::setSupportActionBar) - } - protected fun isDarkAmoledTheme(): Boolean { val uiMode = resources.configuration.uiMode val isNight = uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES @@ -159,7 +165,7 @@ abstract class BaseActivity : override fun isNsfwContent(): Flow = flowOf(false) private fun putDataToExtras(intent: Intent?) { - intent?.putExtra(EXTRA_DATA, intent.data) + intent?.putExtra(AppRouter.KEY_DATA, intent.data) } protected fun setContentViewWebViewSafe(viewBindingProducer: () -> B): Boolean { @@ -178,9 +184,4 @@ abstract class BaseActivity : } protected fun hasViewBinding() = ::viewBinding.isInitialized - - companion object { - - const val EXTRA_DATA = "data" - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt index a611a1b66..a275062dc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseFragment.kt @@ -5,17 +5,18 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat import androidx.fragment.app.Fragment import androidx.viewbinding.ViewBinding import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate -import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate abstract class BaseFragment : + OnApplyWindowInsetsListener, Fragment(), - ExceptionResolver.Host, - WindowInsetsDelegate.WindowInsetsListener { + ExceptionResolver.Host { var viewBinding: B? = null private set @@ -23,9 +24,6 @@ abstract class BaseFragment : protected lateinit var exceptionResolver: ExceptionResolver private set - @JvmField - protected val insetsDelegate = WindowInsetsDelegate() - protected val actionModeDelegate: ActionModeDelegate get() = (requireActivity() as BaseActivity<*>).actionModeDelegate @@ -47,15 +45,12 @@ abstract class BaseFragment : final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - insetsDelegate.onViewCreated(view) - insetsDelegate.addInsetsListener(this) + ViewCompat.setOnApplyWindowInsetsListener(view, this) onViewBindingCreated(requireViewBinding(), savedInstanceState) } override fun onDestroyView() { viewBinding = null - insetsDelegate.removeInsetsListener(this) - insetsDelegate.onDestroyView() super.onDestroyView() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt index db75d16e7..06113c780 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt @@ -1,30 +1,31 @@ package org.koitharu.kotatsu.core.ui -import android.content.ActivityNotFoundException import android.content.Context -import android.content.Intent import android.os.Bundle import android.view.View -import androidx.annotation.CallSuper import androidx.annotation.StringRes -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import androidx.preference.PreferenceScreen import androidx.preference.get import androidx.recyclerview.widget.RecyclerView -import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.EntryPointAccessors import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner -import org.koitharu.kotatsu.core.ui.util.WindowInsetsDelegate +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets +import org.koitharu.kotatsu.core.util.ext.container +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.getThemeDrawable import org.koitharu.kotatsu.core.util.ext.parentView +import org.koitharu.kotatsu.core.util.ext.start +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.settings.SettingsActivity import javax.inject.Inject import com.google.android.material.R as materialR @@ -32,7 +33,7 @@ import com.google.android.material.R as materialR @AndroidEntryPoint abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : PreferenceFragmentCompat(), - WindowInsetsDelegate.WindowInsetsListener, + OnApplyWindowInsetsListener, RecyclerViewOwner, ExceptionResolver.Host { @@ -42,10 +43,7 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : @Inject lateinit var settings: AppSettings - @JvmField - protected val insetsDelegate = WindowInsetsDelegate() - - override val recyclerView: RecyclerView + override val recyclerView: RecyclerView? get() = listView override fun onAttach(context: Context) { @@ -56,17 +54,23 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(view, this) val themedContext = (view.parentView ?: view).context view.setBackgroundColor(themedContext.getThemeColor(android.R.attr.colorBackground)) listView.clipToPadding = false - insetsDelegate.onViewCreated(view) - insetsDelegate.addInsetsListener(this) } - override fun onDestroyView() { - insetsDelegate.removeInsetsListener(this) - insetsDelegate.onDestroyView() - super.onDestroyView() + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + val isTablet = !resources.getBoolean(R.bool.is_tablet) + val isMaster = container?.id == R.id.container_master + listView.setPaddingRelative( + if (isTablet && !isMaster) 0 else barsInsets.start(v), + 0, + if (isTablet && isMaster) 0 else barsInsets.end(v), + barsInsets.bottom, + ) + return insets.consumeAllSystemBarsInsets() } override fun onResume() { @@ -78,25 +82,10 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) : } } - @CallSuper - override fun onWindowInsetsChanged(insets: Insets) { - listView.updatePadding( - bottom = insets.bottom, - ) - } - protected open fun setTitle(title: CharSequence?) { (activity as? SettingsActivity)?.setSectionTitle(title) } - protected fun startActivitySafe(intent: Intent): Boolean = try { - startActivity(intent) - true - } catch (_: ActivityNotFoundException) { - Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() - false - } - private fun focusPreference(key: String) { val pref = findPreference(key) if (pref == null) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt deleted file mode 100644 index 7a8f1463c..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt +++ /dev/null @@ -1,5 +0,0 @@ -package org.koitharu.kotatsu.core.ui - -import androidx.lifecycle.LifecycleService - -abstract class BaseService : LifecycleService() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt index 04c4c7eb6..31316de80 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/CoroutineIntentService.kt @@ -14,6 +14,7 @@ import androidx.core.app.ServiceCompat import androidx.core.content.ContextCompat import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.launch @@ -42,6 +43,8 @@ abstract class CoroutineIntentService : BaseService() { intentJobContext.processIntent(intent) } } + } catch (e: CancellationException) { + throw e } catch (e: Throwable) { e.printStackTraceDebug() intentJobContext.onError(e) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/FragmentContainerActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/FragmentContainerActivity.kt new file mode 100644 index 000000000..6f9d6a543 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/FragmentContainerActivity.kt @@ -0,0 +1,54 @@ +package org.koitharu.kotatsu.core.ui + +import android.os.Bundle +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment +import androidx.fragment.app.commit +import com.google.android.material.appbar.AppBarLayout +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.consumeSystemBarsInsets +import org.koitharu.kotatsu.databinding.ActivityContainerBinding +import org.koitharu.kotatsu.main.ui.owners.AppBarOwner +import org.koitharu.kotatsu.main.ui.owners.SnackbarOwner + +@AndroidEntryPoint +abstract class FragmentContainerActivity(private val fragmentClass: Class) : + BaseActivity(), + AppBarOwner, + SnackbarOwner { + + override val appBar: AppBarLayout + get() = viewBinding.appbar + + override val snackbarHost: CoordinatorLayout + get() = viewBinding.root + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + setContentView(ActivityContainerBinding.inflate(layoutInflater)) + setDisplayHomeAsUp(true, false) + val fm = supportFragmentManager + if (fm.findFragmentById(R.id.container) == null) { + fm.commit { + setReorderingAllowed(true) + replace(R.id.container, fragmentClass, getFragmentExtras()) + } + } + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + viewBinding.appbar.updatePadding( + left = bars.left, + right = bars.right, + top = bars.top, + ) + return insets.consumeSystemBarsInsets(top = true) + } + + protected open fun getFragmentExtras(): Bundle? = intent.extras +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt index bfdf6dde3..e18741e76 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/AlertDialogs.kt @@ -4,6 +4,7 @@ import android.content.Context import android.view.LayoutInflater import android.widget.CompoundButton.OnCheckedChangeListener import androidx.annotation.StringRes +import androidx.annotation.UiContext import androidx.appcompat.app.AlertDialog import androidx.core.view.updatePadding import androidx.recyclerview.widget.LinearLayoutManager @@ -17,7 +18,7 @@ import org.koitharu.kotatsu.databinding.DialogCheckboxBinding import com.google.android.material.R as materialR inline fun buildAlertDialog( - context: Context, + @UiContext context: Context, isCentered: Boolean = false, block: MaterialAlertDialogBuilder.() -> Unit, ): AlertDialog = MaterialAlertDialogBuilder( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CommonAlertDialogs.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CommonAlertDialogs.kt deleted file mode 100644 index 3eaf94219..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/CommonAlertDialogs.kt +++ /dev/null @@ -1,58 +0,0 @@ -package org.koitharu.kotatsu.core.ui.dialog - -import android.content.Context -import android.content.DialogInterface -import androidx.annotation.UiContext -import androidx.core.net.ConnectivityManagerCompat -import dagger.Lazy -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.prefs.TriStateOption -import org.koitharu.kotatsu.core.util.ext.connectivityManager -import javax.inject.Inject - -class CommonAlertDialogs @Inject constructor( - private val settings: Lazy, -) { - - fun askForDownloadOverMeteredNetwork( - @UiContext context: Context, - onConfirmed: (allow: Boolean) -> Unit - ) { - when (settings.get().allowDownloadOnMeteredNetwork) { - TriStateOption.ENABLED -> onConfirmed(true) - TriStateOption.DISABLED -> onConfirmed(false) - TriStateOption.ASK -> { - if (!ConnectivityManagerCompat.isActiveNetworkMetered(context.connectivityManager)) { - onConfirmed(true) - return - } - val listener = DialogInterface.OnClickListener { _, which -> - when (which) { - DialogInterface.BUTTON_POSITIVE -> { - settings.get().allowDownloadOnMeteredNetwork = TriStateOption.ENABLED - onConfirmed(true) - } - - DialogInterface.BUTTON_NEUTRAL -> { - onConfirmed(true) - } - - DialogInterface.BUTTON_NEGATIVE -> { - settings.get().allowDownloadOnMeteredNetwork = TriStateOption.DISABLED - onConfirmed(false) - } - } - } - BigButtonsAlertDialog.Builder(context) - .setIcon(R.drawable.ic_network_cellular) - .setTitle(R.string.download_cellular_confirm) - .setPositiveButton(R.string.allow_always, listener) - .setNeutralButton(R.string.allow_once, listener) - .setNegativeButton(R.string.dont_allow, listener) - .create() - .show() - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/ErrorDetailsDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/ErrorDetailsDialog.kt index 690e2c769..1525ac1e6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/ErrorDetailsDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/ErrorDetailsDialog.kt @@ -1,33 +1,38 @@ package org.koitharu.kotatsu.core.ui.dialog -import android.content.ClipData -import android.content.ClipboardManager -import android.content.Context import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup -import androidx.core.text.HtmlCompat -import androidx.core.text.htmlEncode -import androidx.core.text.method.LinkMovementMethodCompat -import androidx.core.text.parseAsHtml -import androidx.fragment.app.FragmentManager +import androidx.core.view.isVisible import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.github.AppUpdateRepository +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.util.ext.copyToClipboard +import org.koitharu.kotatsu.core.util.ext.getCauseUrl import org.koitharu.kotatsu.core.util.ext.isReportable import org.koitharu.kotatsu.core.util.ext.report import org.koitharu.kotatsu.core.util.ext.requireSerializable -import org.koitharu.kotatsu.core.util.ext.withArgs +import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.DialogErrorDetailsBinding +import javax.inject.Inject -class ErrorDetailsDialog : AlertDialogFragment() { +@AndroidEntryPoint +class ErrorDetailsDialog : AlertDialogFragment(), View.OnClickListener { private lateinit var exception: Throwable + @Inject + lateinit var appUpdateRepository: AppUpdateRepository + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) val args = requireArguments() - exception = args.requireSerializable(ARG_ERROR) + exception = args.requireSerializable(AppRouter.KEY_ERROR) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): DialogErrorDetailsBinding { @@ -36,51 +41,50 @@ class ErrorDetailsDialog : AlertDialogFragment() { override fun onViewBindingCreated(binding: DialogErrorDetailsBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) - with(binding.textViewMessage) { - movementMethod = LinkMovementMethodCompat.getInstance() - text = context.getString( - R.string.manga_error_description_pattern, - exception.message?.htmlEncode().orEmpty(), - arguments?.getString(ARG_URL), - ).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY) - } + binding.buttonBrowser.setOnClickListener(this) + binding.textViewSummary.text = exception.message + val isUrlAvailable = !exception.getCauseUrl().isNullOrEmpty() + binding.buttonBrowser.isVisible = isUrlAvailable + binding.textViewBrowser.isVisible = isUrlAvailable + binding.textViewDescription.setTextAndVisible( + if (appUpdateRepository.isUpdateAvailable) { + R.string.error_disclaimer_app_outdated + } else if (exception.isReportable()) { + R.string.error_disclaimer_report + } else { + 0 + }, + ) } @Suppress("NAME_SHADOWING") override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { val builder = super.onBuildDialog(builder) .setCancelable(true) - .setNegativeButton(android.R.string.cancel, null) - .setTitle(R.string.error_occurred) + .setNegativeButton(R.string.close, null) + .setTitle(R.string.error_details) .setNeutralButton(androidx.preference.R.string.copy) { _, _ -> - copyToClipboard() + context?.copyToClipboard(getString(R.string.error), exception.stackTraceToString()) } - if (exception.isReportable()) { - builder.setPositiveButton(R.string.report) { _, _ -> + if (appUpdateRepository.isUpdateAvailable) { + builder.setPositiveButton(R.string.update) { _, _ -> + router.openAppUpdate() dismiss() + } + } else if (exception.isReportable()) { + builder.setPositiveButton(R.string.report) { _, _ -> exception.report(silent = true) + dismiss() } } return builder } - private fun copyToClipboard() { - val clipboardManager = context?.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - ?: return - clipboardManager.setPrimaryClip( - ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()), + override fun onClick(v: View) { + router.openBrowser( + url = exception.getCauseUrl() ?: return, + source = null, + title = null, ) } - - companion object { - - private const val TAG = "ErrorDetailsDialog" - private const val ARG_ERROR = "error" - private const val ARG_URL = "url" - - fun show(fm: FragmentManager, error: Throwable, url: String?) = ErrorDetailsDialog().withArgs(2) { - putSerializable(ARG_ERROR, error) - putString(ARG_URL, url) - }.show(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedFaviconDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedFaviconDrawable.kt index 24e15150c..2d89252c7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedFaviconDrawable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedFaviconDrawable.kt @@ -6,11 +6,16 @@ import android.graphics.Canvas import android.graphics.drawable.Animatable import androidx.annotation.StyleRes import androidx.interpolator.view.animation.FastOutSlowInInterpolator +import coil3.Image +import coil3.asImage +import coil3.getExtra +import coil3.request.ImageRequest import com.google.android.material.animation.ArgbEvaluatorCompat import com.google.android.material.color.MaterialColors import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.KotatsuColors +import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.util.ext.getAnimationDuration +import org.koitharu.kotatsu.core.util.ext.mangaSourceKey import kotlin.math.abs class AnimatedFaviconDrawable( @@ -23,12 +28,12 @@ class AnimatedFaviconDrawable( private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2 private val timeAnimator = TimeAnimator() - private val colorHigh = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground) - private val colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, colorBackground) + private var colorHigh = MaterialColors.harmonize(colorForeground, currentBackgroundColor) + private var colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor) init { timeAnimator.setTimeListener(this) - updateColor() + onStateChange(state) } override fun draw(canvas: Canvas) { @@ -39,9 +44,11 @@ class AnimatedFaviconDrawable( super.draw(canvas) } - override fun setAlpha(alpha: Int) = Unit - - override fun getAlpha(): Int = 255 + // override fun setAlpha(alpha: Int) = Unit + // + // override fun getAlpha(): Int = 255 + // + // override fun isOpaque(): Boolean = false override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) { callback?.also { @@ -60,13 +67,33 @@ class AnimatedFaviconDrawable( override fun isRunning(): Boolean = timeAnimator.isStarted + override fun onStateChange(state: IntArray): Boolean { + val res = super.onStateChange(state) + colorHigh = MaterialColors.harmonize(currentForegroundColor, currentBackgroundColor) + colorLow = ArgbEvaluatorCompat.getInstance().evaluate(0.3f, colorHigh, currentBackgroundColor) + updateColor() + return res + } + private fun updateColor() { if (period <= 0f) { return } val ph = period / 2 val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat() - colorForeground = ArgbEvaluatorCompat.getInstance() + currentForegroundColor = ArgbEvaluatorCompat.getInstance() .evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh) } + + class Factory( + @StyleRes private val styleResId: Int, + ) : ((ImageRequest) -> Image?) { + + override fun invoke(request: ImageRequest): Image? { + val source = request.getExtra(mangaSourceKey) ?: return null + val context = request.context + val title = source.getTitle(context) + return AnimatedFaviconDrawable(context, styleResId, title).asImage() + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedPlaceholderDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedPlaceholderDrawable.kt index 2fb7aa969..1a22dd0a0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedPlaceholderDrawable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/AnimatedPlaceholderDrawable.kt @@ -7,6 +7,7 @@ import android.graphics.ColorFilter import android.graphics.PixelFormat import android.graphics.drawable.Animatable import android.graphics.drawable.Drawable +import androidx.core.graphics.ColorUtils import androidx.interpolator.view.animation.FastOutSlowInInterpolator import com.google.android.material.animation.ArgbEvaluatorCompat import org.koitharu.kotatsu.R @@ -23,6 +24,7 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti private val interpolator = FastOutSlowInInterpolator() private val period = context.getAnimationDuration(R.integer.config_longAnimTime) * 2 private val timeAnimator = TimeAnimator() + private var currentAlpha: Int = 255 init { timeAnimator.setTimeListener(this) @@ -38,16 +40,17 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti } override fun setAlpha(alpha: Int) { - // this.alpha = alpha FIXME coil's crossfade + currentAlpha = alpha + updateColor() } - override fun setColorFilter(colorFilter: ColorFilter?) = Unit - @Suppress("DeprecatedCallableAddReplaceWith") @Deprecated("Deprecated in Java") - override fun getOpacity(): Int = PixelFormat.OPAQUE + override fun getOpacity(): Int = PixelFormat.TRANSLUCENT - override fun getAlpha(): Int = 255 + override fun getAlpha(): Int = currentAlpha + + override fun setColorFilter(colorFilter: ColorFilter?) = Unit override fun onTimeUpdate(animation: TimeAnimator?, totalTime: Long, deltaTime: Long) { callback?.also { @@ -72,7 +75,10 @@ class AnimatedPlaceholderDrawable(context: Context) : Drawable(), Animatable, Ti } val ph = period / 2 val fraction = abs((System.currentTimeMillis() % period) - ph) / ph.toFloat() - currentColor = ArgbEvaluatorCompat.getInstance() - .evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh) + currentColor = ColorUtils.setAlphaComponent( + ArgbEvaluatorCompat.getInstance() + .evaluate(interpolator.getInterpolation(fraction), colorLow, colorHigh), + currentAlpha + ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt index d90134da7..588543142 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/CoverSizeResolver.kt @@ -1,13 +1,12 @@ package org.koitharu.kotatsu.core.ui.image -import android.view.View -import android.view.View.OnLayoutChangeListener import android.view.ViewGroup +import android.view.ViewTreeObserver +import android.view.ViewTreeObserver.OnPreDrawListener import android.widget.ImageView import coil3.size.Dimension import coil3.size.Size import coil3.size.ViewSizeResolver -import kotlinx.coroutines.CancellableContinuation import kotlinx.coroutines.suspendCancellableCoroutine import kotlin.coroutines.resume import kotlin.math.roundToInt @@ -20,31 +19,67 @@ class CoverSizeResolver( ) : ViewSizeResolver { override suspend fun size(): Size { + // Fast path: the view is already measured. getSize()?.let { return it } - return suspendCancellableCoroutine { cont -> - val layoutListener = LayoutListener(cont) - view.addOnLayoutChangeListener(layoutListener) - cont.invokeOnCancellation { - view.removeOnLayoutChangeListener(layoutListener) + + // Slow path: wait for the view to be measured. + return suspendCancellableCoroutine { continuation -> + val viewTreeObserver = view.viewTreeObserver + + val preDrawListener = object : OnPreDrawListener { + private var isResumed = false + + override fun onPreDraw(): Boolean { + val size = getSize() + if (size != null) { + viewTreeObserver.removePreDrawListenerSafe(this) + + if (!isResumed) { + isResumed = true + continuation.resume(size) + } + } + return true + } + } + + viewTreeObserver.addOnPreDrawListener(preDrawListener) + + continuation.invokeOnCancellation { + viewTreeObserver.removePreDrawListenerSafe(preDrawListener) } } } private fun getSize(): Size? { - val lp = view.layoutParams - var width = getDimension(lp.width, view.width, view.paddingLeft + view.paddingRight) - var height = getDimension(lp.height, view.height, view.paddingTop + view.paddingBottom) - if (width == null && height == null) { - return null - } - if (height == null && width != null) { - height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt()) - } else if (width == null && height != null) { - width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt()) + var width = getWidth() + var height = getHeight() + when { + width == null && height == null -> { + return null + } + height == null && width != null -> { + height = Dimension((width.px * ASPECT_RATIO_HEIGHT / ASPECT_RATIO_WIDTH).roundToInt()) + } + width == null && height != null -> { + width = Dimension((height.px * ASPECT_RATIO_WIDTH / ASPECT_RATIO_HEIGHT).roundToInt()) + } } return Size(checkNotNull(width), checkNotNull(height)) } + private fun getWidth() = getDimension( + paramSize = view.layoutParams?.width ?: -1, + viewSize = view.width, + paddingSize = if (subtractPadding) view.paddingLeft + view.paddingRight else 0 + ) + + private fun getHeight() = getDimension( + paramSize = view.layoutParams?.height ?: -1, + viewSize = view.height, + paddingSize = if (subtractPadding) view.paddingTop + view.paddingBottom else 0 + ) + private fun getDimension(paramSize: Int, viewSize: Int, paddingSize: Int): Dimension.Pixels? { if (paramSize == ViewGroup.LayoutParams.WRAP_CONTENT) { return null @@ -60,24 +95,11 @@ class CoverSizeResolver( return null } - private inner class LayoutListener( - private val continuation: CancellableContinuation, - ) : OnLayoutChangeListener { - - override fun onLayoutChange( - v: View, - left: Int, - top: Int, - right: Int, - bottom: Int, - oldLeft: Int, - oldTop: Int, - oldRight: Int, - oldBottom: Int, - ) { - val size = getSize() ?: return - v.removeOnLayoutChangeListener(this) - continuation.resume(size) + private fun ViewTreeObserver.removePreDrawListenerSafe(victim: OnPreDrawListener) { + if (isAlive) { + removeOnPreDrawListener(victim) + } else { + view.viewTreeObserver.removeOnPreDrawListener(victim) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt index 5beacf886..3d1df1228 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/FaviconDrawable.kt @@ -1,34 +1,47 @@ package org.koitharu.kotatsu.core.ui.image import android.content.Context +import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color -import android.graphics.ColorFilter import android.graphics.Paint import android.graphics.Path -import android.graphics.PixelFormat import android.graphics.Rect import android.graphics.RectF -import android.graphics.drawable.Drawable +import android.os.Build +import androidx.annotation.RequiresApi import androidx.annotation.StyleRes import androidx.core.content.withStyledAttributes import androidx.core.graphics.withClip +import coil3.Image +import coil3.asImage +import coil3.getExtra +import coil3.request.ImageRequest import com.google.android.material.color.MaterialColors import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.util.KotatsuColors +import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified +import org.koitharu.kotatsu.core.util.ext.mangaSourceKey open class FaviconDrawable( context: Context, @StyleRes styleResId: Int, name: String, -) : Drawable() { +) : PaintDrawable() { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - protected var colorBackground = Color.WHITE + override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + protected var currentBackgroundColor = Color.WHITE + private set + private var colorBackground: ColorStateList = ColorStateList.valueOf(currentBackgroundColor) protected var colorForeground = Color.DKGRAY - private var colorStroke = Color.LTGRAY + protected var currentForegroundColor = Color.DKGRAY + protected var currentStrokeColor = Color.LTGRAY + private set + private var colorStroke: ColorStateList = ColorStateList.valueOf(currentStrokeColor) private val letter = name.take(1).uppercase() private var cornerSize = 0f + private var intrinsicSize = -1 private val textBounds = Rect() private val tempRect = Rect() private val boundsF = RectF() @@ -36,14 +49,17 @@ open class FaviconDrawable( init { context.withStyledAttributes(styleResId, R.styleable.FaviconFallbackDrawable) { - colorBackground = getColor(R.styleable.FaviconFallbackDrawable_backgroundColor, colorBackground) - colorStroke = getColor(R.styleable.FaviconFallbackDrawable_strokeColor, colorStroke) + colorBackground = getColorStateList(R.styleable.FaviconFallbackDrawable_backgroundColor) ?: colorBackground + colorStroke = getColorStateList(R.styleable.FaviconFallbackDrawable_strokeColor) ?: colorStroke cornerSize = getDimension(R.styleable.FaviconFallbackDrawable_cornerSize, cornerSize) paint.strokeWidth = getDimension(R.styleable.FaviconFallbackDrawable_strokeWidth, 0f) * 2f + intrinsicSize = getDimensionPixelSize(R.styleable.FaviconFallbackDrawable_drawableSize, intrinsicSize) } paint.textAlign = Paint.Align.CENTER paint.isFakeBoldText = true - colorForeground = MaterialColors.harmonize(KotatsuColors.random(name), colorBackground) + colorForeground = KotatsuColors.random(name) + currentForegroundColor = MaterialColors.harmonize(colorForeground, colorBackground.defaultColor) + onStateChange(state) } override fun draw(canvas: Canvas) { @@ -67,31 +83,42 @@ open class FaviconDrawable( clipPath.close() } - override fun setAlpha(alpha: Int) { - paint.alpha = alpha - } + override fun getIntrinsicWidth(): Int = intrinsicSize - override fun setColorFilter(colorFilter: ColorFilter?) { - paint.colorFilter = colorFilter - } + override fun getIntrinsicHeight(): Int = intrinsicSize + + override fun isOpaque(): Boolean = cornerSize == 0f && colorBackground.isOpaque - @Suppress("DeprecatedCallableAddReplaceWith") - @Deprecated("Deprecated in Java") - override fun getOpacity() = PixelFormat.TRANSPARENT + override fun isStateful(): Boolean = colorStroke.isStateful || colorBackground.isStateful + + @RequiresApi(Build.VERSION_CODES.S) + override fun hasFocusStateSpecified(): Boolean = + colorBackground.hasFocusStateSpecified() || colorStroke.hasFocusStateSpecified() + + override fun onStateChange(state: IntArray): Boolean { + val prevStrokeColor = currentStrokeColor + val prevBackgroundColor = currentBackgroundColor + currentStrokeColor = colorStroke.getColorForState(state, colorStroke.defaultColor) + currentBackgroundColor = colorBackground.getColorForState(state, colorBackground.defaultColor) + if (currentBackgroundColor != prevBackgroundColor) { + currentForegroundColor = MaterialColors.harmonize(colorForeground, currentBackgroundColor) + } + return prevBackgroundColor != currentBackgroundColor || prevStrokeColor != currentStrokeColor + } private fun doDraw(canvas: Canvas) { // background - paint.color = colorBackground + paint.color = currentBackgroundColor paint.style = Paint.Style.FILL canvas.drawPaint(paint) // letter - paint.color = colorForeground + paint.color = currentForegroundColor val cx = (boundsF.left + boundsF.right) * 0.6f val ty = boundsF.bottom * 0.7f + textBounds.height() * 0.5f - textBounds.bottom canvas.drawText(letter, cx, ty, paint) if (paint.strokeWidth > 0f) { // stroke - paint.color = colorStroke + paint.color = currentStrokeColor paint.style = Paint.Style.STROKE canvas.drawPath(clipPath, paint) } @@ -103,4 +130,16 @@ open class FaviconDrawable( paint.getTextBounds(text, 0, text.length, tempRect) return testTextSize * width / tempRect.width() } + + class Factory( + @StyleRes private val styleResId: Int, + ) : ((ImageRequest) -> Image?) { + + override fun invoke(request: ImageRequest): Image? { + val source = request.getExtra(mangaSourceKey) ?: return null + val context = request.context + val title = source.getTitle(context) + return FaviconDrawable(context, styleResId, title).asImage() + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/PaintDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/PaintDrawable.kt new file mode 100644 index 000000000..a5d6a7e91 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/PaintDrawable.kt @@ -0,0 +1,53 @@ +package org.koitharu.kotatsu.core.ui.image + +import android.graphics.ColorFilter +import android.graphics.Paint +import android.graphics.PixelFormat +import android.graphics.drawable.Drawable + +@Suppress("OVERRIDE_DEPRECATION") +abstract class PaintDrawable : Drawable() { + + protected abstract val paint: Paint + + override fun setAlpha(alpha: Int) { + paint.alpha = alpha + } + + override fun getAlpha(): Int { + return paint.alpha + } + + override fun setColorFilter(colorFilter: ColorFilter?) { + paint.colorFilter = colorFilter + } + + override fun getColorFilter(): ColorFilter? { + return paint.colorFilter + } + + override fun setDither(dither: Boolean) { + paint.isDither = dither + } + + override fun setFilterBitmap(filter: Boolean) { + paint.isFilterBitmap = filter + } + + override fun isFilterBitmap(): Boolean { + return paint.isFilterBitmap + } + + override fun getOpacity(): Int { + if (paint.colorFilter != null) { + return PixelFormat.TRANSLUCENT + } + return when (paint.alpha) { + 0 -> PixelFormat.TRANSPARENT + 255 -> if (isOpaque()) PixelFormat.OPAQUE else PixelFormat.TRANSLUCENT + else -> PixelFormat.TRANSLUCENT + } + } + + protected open fun isOpaque() = false +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TextDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TextDrawable.kt new file mode 100644 index 000000000..a31c80f3d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TextDrawable.kt @@ -0,0 +1,83 @@ +package org.koitharu.kotatsu.core.ui.image + +import android.content.res.ColorStateList +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.PointF +import android.graphics.Rect +import android.os.Build +import android.widget.TextView +import androidx.annotation.RequiresApi +import androidx.core.graphics.PaintCompat +import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified + +class TextDrawable( + val text: String, +) : PaintDrawable() { + + override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) + private val textBounds = Rect() + private val textPoint = PointF() + + var textSize: Float + get() = paint.textSize + set(value) { + paint.textSize = value + measureTextBounds() + } + + var textColor: ColorStateList = ColorStateList.valueOf(Color.BLACK) + set(value) { + field = value + onStateChange(state) + } + + init { + onStateChange(state) + measureTextBounds() + } + + override fun draw(canvas: Canvas) { + canvas.drawText(text, textPoint.x, textPoint.y, paint) + } + + override fun onBoundsChange(bounds: Rect) { + textPoint.set( + bounds.exactCenterX() - textBounds.exactCenterX(), + bounds.exactCenterY() - textBounds.exactCenterY(), + ) + } + + override fun getIntrinsicWidth(): Int = textBounds.width() + + override fun getIntrinsicHeight(): Int = textBounds.height() + + override fun isStateful(): Boolean = textColor.isStateful + + @RequiresApi(Build.VERSION_CODES.S) + override fun hasFocusStateSpecified(): Boolean = textColor.hasFocusStateSpecified() + + override fun onStateChange(state: IntArray): Boolean { + val prevColor = paint.color + paint.color = textColor.getColorForState(state, textColor.defaultColor) + return paint.color != prevColor + } + + private fun measureTextBounds() { + paint.getTextBounds(text, 0, text.length, textBounds) + onBoundsChange(bounds) + } + + companion object { + + fun compound(textView: TextView, text: String): TextDrawable? { + val drawable = TextDrawable(text) + drawable.textSize = textView.textSize + drawable.textColor = textView.textColors + return drawable.takeIf { + PaintCompat.hasGlyph(drawable.paint, text) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TextViewTarget.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TextViewTarget.kt new file mode 100644 index 000000000..e2befcad5 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/image/TextViewTarget.kt @@ -0,0 +1,41 @@ +package org.koitharu.kotatsu.core.ui.image + +import android.graphics.drawable.Drawable +import android.view.Gravity +import android.widget.TextView +import androidx.annotation.GravityInt +import coil3.target.GenericViewTarget + +class TextViewTarget( + override val view: TextView, + @GravityInt compoundDrawable: Int, +) : GenericViewTarget() { + + private val drawableIndex: Int = when (compoundDrawable) { + Gravity.START -> 0 + Gravity.TOP -> 2 + Gravity.END -> 3 + Gravity.BOTTOM -> 4 + else -> -1 + } + + override var drawable: Drawable? + get() = if (drawableIndex != -1) { + view.compoundDrawablesRelative[drawableIndex] + } else { + null + } + set(value) { + if (drawableIndex == -1) { + return + } + val drawables = view.compoundDrawablesRelative + drawables[drawableIndex] = value + view.setCompoundDrawablesRelativeWithIntrinsicBounds( + drawables[0], + drawables[1], + drawables[2], + drawables[3], + ) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt index 3a75b4af6..9e28d0ef7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScrollRecyclerView.kt @@ -37,8 +37,8 @@ class FastScrollRecyclerView @JvmOverloads constructor( init { fastScroller.id = R.id.fast_scroller fastScroller.layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.WRAP_CONTENT, - ViewGroup.LayoutParams.MATCH_PARENT, + LayoutParams.WRAP_CONTENT, + LayoutParams.MATCH_PARENT, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt index 0c31b0a1a..1514fc6a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt @@ -11,7 +11,9 @@ import android.view.LayoutInflater import android.view.MotionEvent import android.view.View import android.view.ViewGroup -import android.widget.* +import android.widget.FrameLayout +import android.widget.LinearLayout +import android.widget.RelativeLayout import androidx.annotation.AttrRes import androidx.annotation.ColorInt import androidx.annotation.DimenRes @@ -27,6 +29,7 @@ import androidx.core.view.GravityCompat import androidx.core.view.ancestors import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams import androidx.recyclerview.widget.RecyclerView import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.getThemeColor @@ -162,7 +165,7 @@ class FastScroller @JvmOverloads constructor( override fun onSizeChanged(w: Int, h: Int, oldW: Int, oldH: Int) { super.onSizeChanged(w, h, oldW, oldH) - viewHeight = h + viewHeight = h - paddingTop - paddingBottom } @SuppressLint("ClickableViewAccessibility") @@ -229,6 +232,7 @@ class FastScroller @JvmOverloads constructor( * * @param params The [ViewGroup.LayoutParams] for this view, cannot be null */ + @Suppress("RemoveRedundantQualifierName") override fun setLayoutParams(params: ViewGroup.LayoutParams) { params.width = LayoutParams.WRAP_CONTENT super.setLayoutParams(params) @@ -242,8 +246,8 @@ class FastScroller @JvmOverloads constructor( */ fun setLayoutParams(viewGroup: ViewGroup) { val recyclerViewId = recyclerView?.id ?: NO_ID - val marginTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top) - val marginBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom) + val offsetTop = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_top) + val offsetBottom = resources.getDimensionPixelSize(R.dimen.fastscroll_scrollbar_margin_bottom) require(recyclerViewId != NO_ID) { "RecyclerView must have a view ID" } @@ -260,31 +264,43 @@ class FastScroller @JvmOverloads constructor( applyTo(viewGroup) } - layoutParams = (layoutParams as ConstraintLayout.LayoutParams).apply { + updateLayoutParams { height = 0 - setMargins(offset, marginTop, offset, marginBottom) + marginStart = offset + marginEnd = offset + topMargin = offsetTop + bottomMargin = offsetBottom } } - is CoordinatorLayout -> layoutParams = (layoutParams as CoordinatorLayout.LayoutParams).apply { + is CoordinatorLayout -> updateLayoutParams { height = LayoutParams.MATCH_PARENT anchorGravity = GravityCompat.END anchorId = recyclerViewId - setMargins(offset, marginTop, offset, marginBottom) + marginStart = offset + marginEnd = offset + topMargin = offsetTop + bottomMargin = offsetBottom } - is FrameLayout -> layoutParams = (layoutParams as FrameLayout.LayoutParams).apply { + is FrameLayout -> updateLayoutParams { height = LayoutParams.MATCH_PARENT gravity = GravityCompat.END - setMargins(offset, marginTop, offset, marginBottom) + marginStart = offset + marginEnd = offset + topMargin = offsetTop + bottomMargin = offsetBottom } - is RelativeLayout -> layoutParams = (layoutParams as RelativeLayout.LayoutParams).apply { + is RelativeLayout -> updateLayoutParams { height = 0 addRule(RelativeLayout.ALIGN_TOP, recyclerViewId) addRule(RelativeLayout.ALIGN_BOTTOM, recyclerViewId) addRule(RelativeLayout.ALIGN_END, recyclerViewId) - setMargins(offset, marginTop, offset, marginBottom) + marginStart = offset + marginEnd = offset + topMargin = offsetTop + bottomMargin = offsetBottom } else -> throw IllegalArgumentException("Parent ViewGroup must be a ConstraintLayout, CoordinatorLayout, FrameLayout, or RelativeLayout") @@ -529,7 +545,7 @@ class FastScroller @JvmOverloads constructor( private fun findValidParent(view: View): ViewGroup? = view.ancestors.firstNotNullOfOrNull { p -> if (p is FrameLayout || p is ConstraintLayout || p is CoordinatorLayout || p is RelativeLayout) { - p as ViewGroup + p } else { null } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt index 285a6ce0a..263621a5c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/model/DateTimeAgo.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.ui.model import android.content.Context import android.text.format.DateUtils import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.toMillis import java.time.LocalDate @@ -22,7 +23,7 @@ sealed class DateTimeAgo { data class MinutesAgo(val minutes: Int) : DateTimeAgo() { override fun format(context: Context): String { - return context.resources.getQuantityString( + return context.resources.getQuantityStringSafe( R.plurals.minutes_ago, minutes, minutes, @@ -34,7 +35,7 @@ sealed class DateTimeAgo { data class HoursAgo(val hours: Int) : DateTimeAgo() { override fun format(context: Context): String { - return context.resources.getQuantityString( + return context.resources.getQuantityStringSafe( R.plurals.hours_ago, hours, hours, @@ -66,7 +67,7 @@ sealed class DateTimeAgo { data class DaysAgo(val days: Int) : DateTimeAgo() { override fun format(context: Context): String { - return context.resources.getQuantityString(R.plurals.days_ago, days, days) + return context.resources.getQuantityStringSafe(R.plurals.days_ago, days, days) } override fun toString() = "days_ago_$days" @@ -77,7 +78,7 @@ sealed class DateTimeAgo { return if (months == 0) { context.getString(R.string.this_month) } else { - context.resources.getQuantityString( + context.resources.getQuantityStringSafe( R.plurals.months_ago, months, months, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt index 5d239f589..663aa0ffa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BaseAdaptiveSheet.kt @@ -14,6 +14,8 @@ import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDialog import androidx.appcompat.app.AppCompatDialogFragment import androidx.appcompat.view.ActionMode +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat import androidx.core.view.updateLayoutParams import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.LifecycleOwner @@ -29,7 +31,9 @@ import org.koitharu.kotatsu.core.ui.BaseActivityEntryPoint import org.koitharu.kotatsu.core.ui.util.ActionModeDelegate import com.google.android.material.R as materialR -abstract class BaseAdaptiveSheet : AppCompatDialogFragment(), ExceptionResolver.Host { +abstract class BaseAdaptiveSheet : AppCompatDialogFragment(), + OnApplyWindowInsetsListener, + ExceptionResolver.Host { private var waitingForDismissAllowingStateLoss = false private var isFitToContentsDisabled = false @@ -74,6 +78,7 @@ abstract class BaseAdaptiveSheet : AppCompatDialogFragment(), E final override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) + ViewCompat.setOnApplyWindowInsetsListener(view, this) val binding = requireViewBinding() if (actionModeDelegate == null) { actionModeDelegate = (activity as? BaseActivity<*>)?.actionModeDelegate diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BottomSheetCollapseCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BottomSheetCollapseCallback.kt index ee6a25c58..f8144b533 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BottomSheetCollapseCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/sheet/BottomSheetCollapseCallback.kt @@ -21,19 +21,12 @@ class BottomSheetCollapseCallback( object : BottomSheetBehavior.BottomSheetCallback() { @SuppressLint("SwitchIntDef") - override fun onStateChanged(view: View, state: Int) { - when (state) { - STATE_EXPANDED, - STATE_HALF_EXPANDED -> isEnabled = true - - STATE_COLLAPSED, - STATE_HIDDEN -> isEnabled = false - } - } + override fun onStateChanged(view: View, state: Int) = onStateChanged(state) override fun onSlide(p0: View, p1: Float) = Unit }, ) + onStateChanged(behavior.state) } override fun handleOnBackPressed() = behavior.handleBackInvoked() @@ -43,4 +36,14 @@ class BottomSheetCollapseCallback( override fun handleOnBackProgressed(backEvent: BackEventCompat) = behavior.updateBackProgress(backEvent) override fun handleOnBackStarted(backEvent: BackEventCompat) = behavior.startBackProgress(backEvent) + + private fun onStateChanged(state: Int) { + when (state) { + STATE_EXPANDED, + STATE_HALF_EXPANDED -> isEnabled = true + + STATE_COLLAPSED, + STATE_HIDDEN -> isEnabled = false + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt index f34963f15..0e30fd085 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/RecyclerViewOwner.kt @@ -4,5 +4,5 @@ import androidx.recyclerview.widget.RecyclerView interface RecyclerViewOwner { - val recyclerView: RecyclerView + val recyclerView: RecyclerView? } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt deleted file mode 100644 index aa3ce78d1..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/util/WindowInsetsDelegate.kt +++ /dev/null @@ -1,77 +0,0 @@ -package org.koitharu.kotatsu.core.ui.util - -import android.view.View -import androidx.core.graphics.Insets -import androidx.core.view.OnApplyWindowInsetsListener -import androidx.core.view.ViewCompat -import androidx.core.view.WindowInsetsCompat -import java.util.LinkedList - -class WindowInsetsDelegate : OnApplyWindowInsetsListener, View.OnLayoutChangeListener { - - @JvmField - var handleImeInsets: Boolean = false - - @JvmField - var interceptingWindowInsetsListener: OnApplyWindowInsetsListener? = null - - private val listeners = LinkedList() - private var lastInsets: Insets? = null - - override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { - val handledInsets = interceptingWindowInsetsListener?.onApplyWindowInsets(v, insets) ?: insets - val newInsets = if (handleImeInsets) { - Insets.max( - handledInsets.getInsets(WindowInsetsCompat.Type.systemBars()), - handledInsets.getInsets(WindowInsetsCompat.Type.ime()), - ) - } else { - handledInsets.getInsets(WindowInsetsCompat.Type.systemBars()) - } - if (newInsets != lastInsets) { - listeners.forEach { it.onWindowInsetsChanged(newInsets) } - lastInsets = newInsets - } - return handledInsets - } - - override fun onLayoutChange( - view: View, - left: Int, - top: Int, - right: Int, - bottom: Int, - oldLeft: Int, - oldTop: Int, - oldRight: Int, - oldBottom: Int, - ) { - view.removeOnLayoutChangeListener(this) - if (lastInsets == null) { // Listener may not be called - onApplyWindowInsets(view, ViewCompat.getRootWindowInsets(view) ?: return) - } - } - - fun addInsetsListener(listener: WindowInsetsListener) { - listeners.add(listener) - lastInsets?.let { listener.onWindowInsetsChanged(it) } - } - - fun removeInsetsListener(listener: WindowInsetsListener) { - listeners.remove(listener) - } - - fun onViewCreated(view: View) { - ViewCompat.setOnApplyWindowInsetsListener(view, this) - view.addOnLayoutChangeListener(this) - } - - fun onDestroyView() { - lastInsets = null - } - - fun interface WindowInsetsListener { - - fun onWindowInsetsChanged(insets: Insets) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/BadgeView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/BadgeView.kt new file mode 100644 index 000000000..85e7f34f6 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/BadgeView.kt @@ -0,0 +1,100 @@ +package org.koitharu.kotatsu.core.ui.widgets + +import android.content.Context +import android.os.Parcel +import android.os.Parcelable +import android.os.Parcelable.Creator +import android.util.AttributeSet +import androidx.core.content.withStyledAttributes +import androidx.customview.view.AbsSavedState +import com.google.android.material.shape.MaterialShapeDrawable +import com.google.android.material.shape.ShapeAppearanceModel +import com.google.android.material.textview.MaterialTextView +import org.koitharu.kotatsu.R + +class BadgeView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null +) : MaterialTextView(context, attrs, R.attr.badgeViewStyle) { + + private var maxCharacterCount = Int.MAX_VALUE + + var number: Int = 0 + set(value) { + field = value + updateText() + } + + init { + context.withStyledAttributes(attrs, R.styleable.BadgeView, R.attr.badgeViewStyle) { + maxCharacterCount = getInt(R.styleable.BadgeView_maxCharacterCount, maxCharacterCount) + number = getInt(R.styleable.BadgeView_number, number) + val shape = ShapeAppearanceModel.builder( + context, + getResourceId(R.styleable.BadgeView_shapeAppearance, 0), + 0, + ).build() + background = MaterialShapeDrawable(shape).also { bg -> + bg.fillColor = getColorStateList(R.styleable.BadgeView_backgroundColor) + } + } + } + + override fun onSaveInstanceState(): Parcelable? { + val superState = super.onSaveInstanceState() ?: return null + return SavedState(superState, number) + } + + override fun onRestoreInstanceState(state: Parcelable?) { + if (state is SavedState) { + super.onRestoreInstanceState(state.superState) + number = state.number + } else { + super.onRestoreInstanceState(state) + } + } + + private fun updateText() { + if (number <= 0) { + text = null + return + } + val numberString = number.toString() + text = if (numberString.length > maxCharacterCount) { + buildString(maxCharacterCount) { + repeat(maxCharacterCount - 1) { append('9') } + append('+') + } + } else { + numberString + } + } + + private class SavedState : AbsSavedState { + + val number: Int + + constructor(superState: Parcelable, number: Int) : super(superState) { + this.number = number + } + + constructor(source: Parcel, classLoader: ClassLoader?) : super(source, classLoader) { + number = source.readInt() + } + + override fun writeToParcel(out: Parcel, flags: Int) { + super.writeToParcel(out, flags) + out.writeInt(number) + } + + companion object { + @Suppress("unused") + @JvmField + val CREATOR: Creator = object : Creator { + override fun createFromParcel(`in`: Parcel) = SavedState(`in`, SavedState::class.java.classLoader) + + override fun newArray(size: Int): Array = arrayOfNulls(size) + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt index 3a5d98268..599879360 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ChipsView.kt @@ -3,10 +3,10 @@ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context import android.util.AttributeSet import android.view.View -import android.view.View.OnClickListener import androidx.annotation.ColorRes import androidx.annotation.DrawableRes import androidx.annotation.StringRes +import androidx.core.content.ContextCompat import androidx.core.view.children import coil3.ImageLoader import coil3.request.Disposable @@ -34,7 +34,7 @@ import com.google.android.material.R as materialR class ChipsView @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, - defStyleAttr: Int = com.google.android.material.R.attr.chipGroupStyle, + defStyleAttr: Int = materialR.attr.chipGroupStyle, ) : ChipGroup(context, attrs, defStyleAttr) { @Inject @@ -49,6 +49,7 @@ class ChipsView @JvmOverloads constructor( onChipCloseClickListener?.onChipCloseClick(chip, data) ?: onChipClickListener?.onChipClick(chip, data) } private val chipStyle: Int + private val iconsVisible: Boolean var onChipClickListener: OnChipClickListener? = null set(value) { field = value @@ -60,6 +61,7 @@ class ChipsView @JvmOverloads constructor( init { val ta = context.obtainStyledAttributes(attrs, R.styleable.ChipsView, defStyleAttr, 0) chipStyle = ta.getResourceId(R.styleable.ChipsView_chipStyle, R.style.Widget_Kotatsu_Chip) + iconsVisible = ta.getBoolean(R.styleable.ChipsView_chipIconVisible, true) ta.recycle() if (isInEditMode) { @@ -124,6 +126,9 @@ class ChipsView @JvmOverloads constructor( private var model: ChipModel? = null private var imageRequest: Disposable? = null + private val defaultStrokeColor = chipStrokeColor + private val defaultTextColor = textColors + init { val drawable = ChipDrawable.createFromAttributes(context, null, 0, chipStyle) setChipDrawable(drawable) @@ -153,6 +158,14 @@ class ChipsView @JvmOverloads constructor( isChecked = false isCheckable = false } + if (model.tint == 0) { + chipStrokeColor = defaultStrokeColor + setTextColor(defaultTextColor) + } else { + val tint = ContextCompat.getColorStateList(context, model.tint) + chipStrokeColor = tint + setTextColor(tint) + } bindIcon(model) isCheckedIconVisible = model.isChecked isCloseIconVisible = if (model.isCloseable || model.isDropdown) { @@ -170,12 +183,7 @@ class ChipsView @JvmOverloads constructor( private fun bindIcon(model: ChipModel) { when { - model.isChecked -> { - imageRequest?.dispose() - imageRequest = null - chipIcon = null - isChipIconVisible = false - } + model.isChecked -> disposeIcon() model.isLoading -> { imageRequest?.dispose() @@ -184,6 +192,8 @@ class ChipsView @JvmOverloads constructor( setProgressIcon() } + !iconsVisible -> disposeIcon() + model.iconData != null -> { val placeholder = model.icon.ifZero { materialR.drawable.navigation_empty_icon } imageRequest = ImageRequest.Builder(context) @@ -207,14 +217,16 @@ class ChipsView @JvmOverloads constructor( isChipIconVisible = true } - else -> { - imageRequest?.dispose() - imageRequest = null - chipIcon = null - isChipIconVisible = false - } + else -> disposeIcon() } } + + private fun disposeIcon() { + imageRequest?.dispose() + imageRequest = null + chipIcon = null + isChipIconVisible = false + } } private inner class InternalChipClickListener : OnClickListener { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/DotsIndicator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/DotsIndicator.kt index 17eaaca1d..cf5e55b62 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/DotsIndicator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/DotsIndicator.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.core.ui.widgets import android.content.Context +import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint @@ -11,7 +12,7 @@ import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.AdapterDataObserver import androidx.viewpager2.widget.ViewPager2 import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList import org.koitharu.kotatsu.core.util.ext.measureDimension import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.parsers.util.toIntUp @@ -30,6 +31,7 @@ class DotsIndicator @JvmOverloads constructor( private var smallDotAlpha = 0.6f private var positionOffset: Float = 0f private var position: Int = 0 + private var dotsColor: ColorStateList = ColorStateList.valueOf(Color.DKGRAY) private val inset = context.resources.resolveDp(1f) var max: Int = 6 @@ -52,10 +54,10 @@ class DotsIndicator @JvmOverloads constructor( init { paint.style = Paint.Style.FILL context.withStyledAttributes(attrs, R.styleable.DotsIndicator, defStyleAttr) { - paint.color = getColor( - R.styleable.DotsIndicator_dotColor, - context.getThemeColor(materialR.attr.colorOnBackground, Color.DKGRAY), - ) + dotsColor = getColorStateList(R.styleable.DotsIndicator_dotColor) + ?: context.getThemeColorStateList(materialR.attr.colorOnBackground) + ?: dotsColor + paint.color = dotsColor.getColorForState(drawableState, dotsColor.defaultColor) indicatorSize = getDimension(R.styleable.DotsIndicator_dotSize, indicatorSize) dotSpacing = getDimension(R.styleable.DotsIndicator_dotSpacing, dotSpacing) smallDotScale = getFloat(R.styleable.DotsIndicator_dotScale, smallDotScale).coerceIn(0f, 1f) @@ -89,6 +91,13 @@ class DotsIndicator @JvmOverloads constructor( } } + override fun drawableStateChanged() { + if (dotsColor.isStateful) { + paint.color = dotsColor.getColorForState(drawableState, dotsColor.defaultColor) + } + super.drawableStateChanged() + } + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { val dotSize = getDotSize() val desiredHeight = (dotSize + paddingTop + paddingBottom).toIntUp() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/IconsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/IconsView.kt new file mode 100644 index 000000000..8ff4b8dc4 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/IconsView.kt @@ -0,0 +1,90 @@ +package org.koitharu.kotatsu.core.ui.widgets + +import android.content.Context +import android.graphics.drawable.Drawable +import android.util.AttributeSet +import android.widget.ImageView +import android.widget.LinearLayout +import androidx.annotation.DrawableRes +import androidx.core.content.withStyledAttributes +import androidx.core.view.isVisible +import org.koitharu.kotatsu.R + +class IconsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : LinearLayout(context, attrs) { + + private var iconSize = LayoutParams.WRAP_CONTENT + private var iconSpacing = 0 + + val iconsCount: Int + get() { + var count = 0 + repeat(childCount) { i -> + if (getChildAt(i).isVisible) { + count++ + } + } + return count + } + + init { + context.withStyledAttributes(attrs, R.styleable.IconsView) { + iconSize = getDimensionPixelSize(R.styleable.IconsView_iconSize, iconSize) + iconSpacing = getDimensionPixelOffset(R.styleable.IconsView_iconSpacing, iconSpacing) + } + } + + fun setIcons(icons: Iterable) { + var index = 0 + for (icon in icons) { + val imageView = (getChildAt(index) as ImageView?) ?: addImageView() + imageView.setImageDrawable(icon) + imageView.isVisible = true + index++ + } + for (i in index until childCount) { + val imageView = getChildAt(i) as? ImageView ?: continue + imageView.setImageDrawable(null) + imageView.isVisible = false + } + } + + fun clearIcons() { + repeat(childCount) { i -> + getChildAt(i).isVisible = false + } + } + + fun addIcon(drawable: Drawable) { + val imageView = getNextImageView() + imageView.setImageDrawable(drawable) + imageView.isVisible = true + } + + fun addIcon(@DrawableRes resId: Int) { + val imageView = getNextImageView() + imageView.setImageResource(resId) + imageView.isVisible = true + } + + private fun getNextImageView(): ImageView { + repeat(childCount) { i -> + val child = getChildAt(i) + if (child is ImageView && !child.isVisible) { + return child + } + } + return addImageView() + } + + private fun addImageView() = ImageView(context).also { + it.scaleType = ImageView.ScaleType.FIT_CENTER + val lp = LayoutParams(iconSize, iconSize) + if (childCount != 0) { + lp.marginStart = iconSpacing + } + addView(it, lp) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ProgressButton.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ProgressButton.kt deleted file mode 100644 index 133b66071..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ProgressButton.kt +++ /dev/null @@ -1,182 +0,0 @@ -package org.koitharu.kotatsu.core.ui.widgets - -import android.animation.ValueAnimator -import android.content.Context -import android.content.res.ColorStateList -import android.graphics.Canvas -import android.graphics.Color -import android.graphics.Outline -import android.graphics.Paint -import android.util.AttributeSet -import android.view.Gravity -import android.view.View -import android.view.ViewOutlineProvider -import android.view.animation.AccelerateDecelerateInterpolator -import android.widget.TextView -import androidx.annotation.StringRes -import androidx.appcompat.widget.LinearLayoutCompat -import androidx.core.content.withStyledAttributes -import androidx.core.graphics.ColorUtils -import androidx.core.view.children -import androidx.core.widget.TextViewCompat -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList -import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.core.util.ext.resolveDp -import org.koitharu.kotatsu.core.util.ext.setTextAndVisible -import org.koitharu.kotatsu.core.util.ext.textAndVisible -import com.google.android.material.R as materialR - -class ProgressButton @JvmOverloads constructor( - context: Context, - attrs: AttributeSet? = null, - defStyleAttr: Int = 0, -) : LinearLayoutCompat(context, attrs, defStyleAttr), ValueAnimator.AnimatorUpdateListener { - - private val textViewTitle = TextView(context) - private val textViewSubtitle = TextView(context) - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) - - private var progress = 0f - private var targetProgress = 0f - private var colorBase: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT) - private var colorProgress: ColorStateList = ColorStateList.valueOf(Color.TRANSPARENT) - private var progressAnimator: ValueAnimator? = null - - private var colorBaseCurrent = colorProgress.defaultColor - private var colorProgressCurrent = colorProgress.defaultColor - - var title: CharSequence? - get() = textViewTitle.textAndVisible - set(value) { - textViewTitle.textAndVisible = value - } - - var subtitle: CharSequence? - get() = textViewSubtitle.textAndVisible - set(value) { - textViewSubtitle.textAndVisible = value - } - - init { - orientation = VERTICAL - outlineProvider = OutlineProvider() - clipToOutline = true - - context.withStyledAttributes(attrs, R.styleable.ProgressButton, defStyleAttr) { - val textAppearanceFallback = androidx.appcompat.R.style.TextAppearance_AppCompat - TextViewCompat.setTextAppearance( - textViewTitle, - getResourceId(R.styleable.ProgressButton_titleTextAppearance, textAppearanceFallback), - ) - TextViewCompat.setTextAppearance( - textViewSubtitle, - getResourceId(R.styleable.ProgressButton_subtitleTextAppearance, textAppearanceFallback), - ) - textViewTitle.text = getText(R.styleable.ProgressButton_title) - textViewSubtitle.text = getText(R.styleable.ProgressButton_subtitle) - colorBase = getColorStateList(R.styleable.ProgressButton_baseColor) - ?: context.getThemeColorStateList(materialR.attr.colorPrimaryContainer) ?: colorBase - colorProgress = getColorStateList(R.styleable.ProgressButton_progressColor) - ?: context.getThemeColorStateList(materialR.attr.colorPrimary) ?: colorProgress - getColorStateList(R.styleable.ProgressButton_android_textColor)?.let { colorText -> - textViewTitle.setTextColor(colorText) - textViewSubtitle.setTextColor(colorText) - } - progress = getInt(R.styleable.ProgressButton_android_progress, 0).toFloat() / - getInt(R.styleable.ProgressButton_android_max, 100).toFloat() - } - - addView(textViewTitle, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT)) - addView( - textViewSubtitle, - LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT).also { lp -> - lp.topMargin = context.resources.resolveDp(2) - }, - ) - - paint.style = Paint.Style.FILL - applyGravity() - setWillNotDraw(false) - } - - override fun onDraw(canvas: Canvas) { - super.onDraw(canvas) - canvas.drawColor(colorBaseCurrent) - if (progress > 0f) { - canvas.drawRect(0f, 0f, width * progress, height.toFloat(), paint) - } - } - - override fun drawableStateChanged() { - super.drawableStateChanged() - val state = drawableState - colorBaseCurrent = colorBase.getColorForState(state, colorBase.defaultColor) - colorProgressCurrent = colorProgress.getColorForState(state, colorProgress.defaultColor) - colorProgressCurrent = ColorUtils.setAlphaComponent(colorProgressCurrent, 84 /* 255 * 0.33F */) - paint.color = colorProgressCurrent - } - - override fun setGravity(gravity: Int) { - super.setGravity(gravity) - if (childCount != 0) { - applyGravity() - } - } - - override fun setEnabled(enabled: Boolean) { - super.setEnabled(enabled) - children.forEach { it.isEnabled = enabled } - } - - override fun onAnimationUpdate(animation: ValueAnimator) { - if (animation === progressAnimator) { - progress = animation.animatedValue as Float - invalidate() - } - } - - fun setTitle(@StringRes titleResId: Int) { - textViewTitle.setTextAndVisible(titleResId) - } - - fun setSubtitle(@StringRes titleResId: Int) { - textViewSubtitle.setTextAndVisible(titleResId) - } - - fun setProgress(value: Float, animate: Boolean) { - val prevAnimator = progressAnimator - if (animate && context.isAnimationsEnabled) { - if (value == targetProgress) { - return - } - targetProgress = value - progressAnimator = ValueAnimator.ofFloat(progress, value).apply { - duration = context.getAnimationDuration(android.R.integer.config_mediumAnimTime) - interpolator = AccelerateDecelerateInterpolator() - addUpdateListener(this@ProgressButton) - } - progressAnimator?.start() - } else { - progressAnimator = null - progress = value - targetProgress = value - invalidate() - } - prevAnimator?.cancel() - } - - private fun applyGravity() { - val value = (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) or Gravity.CENTER_VERTICAL - textViewTitle.gravity = value - textViewSubtitle.gravity = value - } - - private class OutlineProvider : ViewOutlineProvider() { - - override fun getOutline(view: View, outline: Outline) { - outline.setRoundRect(0, 0, view.width, view.height, view.height / 2f) - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SlidingBottomNavigationView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SlidingBottomNavigationView.kt index 0fc9f816d..c7413a149 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SlidingBottomNavigationView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/SlidingBottomNavigationView.kt @@ -77,6 +77,11 @@ class SlidingBottomNavigationView @JvmOverloads constructor( } } + override fun setMinimumHeight(minHeight: Int) { + super.setMinimumHeight(minHeight) + getChildAt(0)?.minimumHeight = minHeight + } + fun show() { if (currentState == STATE_UP) { return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt index 57870cf19..a0deadc0b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/WindowInsetHolder.kt @@ -10,6 +10,8 @@ import android.widget.LinearLayout import androidx.annotation.AttrRes import androidx.coordinatorlayout.widget.CoordinatorLayout import androidx.core.view.WindowInsetsCompat +import org.koitharu.kotatsu.core.util.ext.end +import org.koitharu.kotatsu.core.util.ext.start class WindowInsetHolder @JvmOverloads constructor( context: Context, @@ -24,9 +26,9 @@ class WindowInsetHolder @JvmOverloads constructor( val barsInsets = WindowInsetsCompat.toWindowInsetsCompat(insets, this) .getInsets(WindowInsetsCompat.Type.systemBars()) val gravity = getLayoutGravity() - val newWidth = when (gravity and Gravity.HORIZONTAL_GRAVITY_MASK) { - Gravity.LEFT -> barsInsets.left - Gravity.RIGHT -> barsInsets.right + val newWidth = when (gravity and Gravity.RELATIVE_HORIZONTAL_GRAVITY_MASK) { + Gravity.START -> barsInsets.start(this) + Gravity.END -> barsInsets.end(this) else -> 0 } val newHeight = when (gravity and Gravity.VERTICAL_GRAVITY_MASK) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ZoomControl.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ZoomControl.kt index b6f8d222a..52625e94e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ZoomControl.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/widgets/ZoomControl.kt @@ -4,14 +4,14 @@ import android.content.Context import android.util.AttributeSet import android.view.LayoutInflater import android.view.View -import android.widget.LinearLayout +import com.google.android.material.button.MaterialButtonGroup import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ViewZoomBinding class ZoomControl @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, -) : LinearLayout(context, attrs), View.OnClickListener { +) : MaterialButtonGroup(context, attrs), View.OnClickListener { private val binding = ViewZoomBinding.inflate(LayoutInflater.from(context), this) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/EditTextValidator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/EditTextValidator.kt index 3554fc194..e00d05efb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/EditTextValidator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/EditTextValidator.kt @@ -2,13 +2,13 @@ package org.koitharu.kotatsu.core.util import android.content.Context import android.text.Editable -import android.text.TextWatcher import android.widget.EditText import androidx.annotation.CallSuper +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import java.lang.ref.WeakReference -abstract class EditTextValidator : TextWatcher { +abstract class EditTextValidator : DefaultTextWatcher { private var editTextRef: WeakReference? = null @@ -17,10 +17,6 @@ abstract class EditTextValidator : TextWatcher { "EditTextValidator is not attached to EditText" } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - @CallSuper override fun afterTextChanged(s: Editable?) { val editText = editTextRef?.get() ?: return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/KotatsuColors.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/KotatsuColors.kt index 915afdb7c..9d758f827 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/KotatsuColors.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/KotatsuColors.kt @@ -13,6 +13,7 @@ import kotlin.math.absoluteValue object KotatsuColors { @ColorInt + @Deprecated("") fun segmentColor(context: Context, @AttrRes resId: Int): Int { val colorHex = String.format("%06x", context.getThemeColor(resId)) val hue = getHue(colorHex) @@ -21,6 +22,13 @@ object KotatsuColors { return MaterialColors.harmonize(color, backgroundColor) } + @ColorInt + fun segmentColorRandom(context: Context, seed: Any): Int { + val color = random(seed) + val backgroundColor = context.getThemeColor(R.attr.colorSurfaceContainerHigh) + return MaterialColors.harmonize(color, backgroundColor) + } + @ColorInt fun random(seed: Any): Int { val hue = (seed.hashCode() % 360).absoluteValue.toFloat() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleStringComparator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleStringComparator.kt new file mode 100644 index 000000000..0bb5b7ec4 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleStringComparator.kt @@ -0,0 +1,36 @@ +package org.koitharu.kotatsu.core.util + +import androidx.core.os.LocaleListCompat +import org.koitharu.kotatsu.core.util.ext.indexOfContains +import org.koitharu.kotatsu.core.util.ext.iterator + +class LocaleStringComparator : Comparator { + + private val deviceLocales: List + + init { + val localeList = LocaleListCompat.getAdjustedDefault() + deviceLocales = buildList(localeList.size() + 1) { + add(null) + val set = HashSet(localeList.size() + 1) + set.add(null) + for (locale in localeList) { + val lang = locale.getDisplayLanguage(locale) + if (set.add(lang)) { + add(lang) + } + } + } + } + + override fun compare(a: String?, b: String?): Int { + val indexA = deviceLocales.indexOfContains(a, true) + val indexB = deviceLocales.indexOfContains(b, true) + return when { + indexA < 0 && indexB < 0 -> compareValues(a, b) + indexA < 0 -> 1 + indexB < 0 -> -1 + else -> compareValues(indexA, indexB) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleUtils.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleUtils.kt new file mode 100644 index 000000000..ae0af00fd --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/LocaleUtils.kt @@ -0,0 +1,37 @@ +package org.koitharu.kotatsu.core.util + +import android.graphics.Paint +import androidx.core.graphics.PaintCompat +import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty +import java.util.Locale + +object LocaleUtils { + + private val paint = Paint() + + fun getEmojiFlag(locale: Locale): String? { + val code = when (val c = locale.country.ifNullOrEmpty { locale.toLanguageTag() }.uppercase(Locale.ENGLISH)) { + "EN" -> "GB" + "JA" -> "JP" + "VI" -> "VN" + "ZH" -> "CN" + else -> c + } + val emoji = countryCodeToEmojiFlag(code) + return if (PaintCompat.hasGlyph(paint, emoji)) { + emoji + } else { + null + } + } + + private fun countryCodeToEmojiFlag(countryCode: String): String { + return countryCode.map { char -> + Character.codePointAt("$char", 0) - 0x41 + 0x1F1E6 + }.map { codePoint -> + Character.toChars(codePoint) + }.joinToString(separator = "") { charArray -> + String(charArray) + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MimeTypes.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MimeTypes.kt new file mode 100644 index 000000000..b1f9ae91f --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/MimeTypes.kt @@ -0,0 +1,46 @@ +package org.koitharu.kotatsu.core.util + +import android.os.Build +import android.webkit.MimeTypeMap +import org.jetbrains.annotations.Blocking +import org.koitharu.kotatsu.core.util.ext.MimeType +import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull +import org.koitharu.kotatsu.parsers.util.nullIfEmpty +import org.koitharu.kotatsu.parsers.util.removeSuffix +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import java.io.File +import java.nio.file.Files +import coil3.util.MimeTypeMap as CoilMimeTypeMap + +object MimeTypes { + + fun getMimeTypeFromExtension(fileName: String): MimeType? { + return CoilMimeTypeMap.getMimeTypeFromExtension(getNormalizedExtension(fileName) ?: return null) + ?.toMimeTypeOrNull() + } + + fun getMimeTypeFromUrl(url: String): MimeType? { + return CoilMimeTypeMap.getMimeTypeFromUrl(url)?.toMimeTypeOrNull() + } + + fun getExtension(mimeType: MimeType?): String? { + return MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType?.toString() ?: return null)?.nullIfEmpty() + } + + @Blocking + fun probeMimeType(file: File): MimeType? { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + runCatchingCancellable { + Files.probeContentType(file.toPath())?.toMimeTypeOrNull() + }.getOrNull()?.let { return it } + } + return getMimeTypeFromExtension(file.name) + } + + fun getNormalizedExtension(name: String): String? = name + .lowercase() + .removeSuffix('~') + .removeSuffix(".tmp") + .substringAfterLast('.', "") + .takeIf { it.length in 2..5 } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt index 3fa1b6e3f..4c08b9d22 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ShareHelper.kt @@ -15,6 +15,7 @@ private const val TYPE_TEXT = "text/plain" private const val TYPE_IMAGE = "image/*" private const val TYPE_CBZ = "application/x-cbz" +@Deprecated("") class ShareHelper(private val context: Context) { fun shareMangaLink(manga: Manga) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/SynchronizedSieveCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/SynchronizedSieveCache.kt new file mode 100644 index 000000000..09b10e494 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/SynchronizedSieveCache.kt @@ -0,0 +1,36 @@ +package org.koitharu.kotatsu.core.util + +import androidx.collection.SieveCache + +class SynchronizedSieveCache( + private val delegate: SieveCache, +) { + + constructor(maxSize: Int) : this(SieveCache(maxSize)) + + private val lock = Any() + + operator fun get(key: K): V? = synchronized(lock) { + delegate[key] + } + + fun put(key: K, value: V): V? = synchronized(lock) { + delegate.put(key, value) + } + + fun remove(key: K) = synchronized(lock) { + delegate.remove(key) + } + + fun evictAll() = synchronized(lock) { + delegate.evictAll() + } + + fun trimToSize(maxSize: Int) = synchronized(lock) { + delegate.trimToSize(maxSize) + } + + fun removeIf(predicate: (K, V) -> Boolean) = synchronized(lock) { + delegate.removeIf(predicate) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt index 7f1a649e0..9f301b28f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt @@ -5,8 +5,9 @@ import android.annotation.SuppressLint import android.app.Activity import android.app.ActivityManager import android.app.ActivityManager.MemoryInfo -import android.app.ActivityOptions import android.app.LocaleConfig +import android.content.ClipData +import android.content.ClipboardManager import android.content.ComponentName import android.content.Context import android.content.Context.ACTIVITY_SERVICE @@ -14,32 +15,27 @@ import android.content.Context.POWER_SERVICE import android.content.ContextWrapper import android.content.Intent import android.content.OperationApplicationException -import android.content.SharedPreferences import android.content.SyncResult import android.content.pm.PackageManager.PERMISSION_GRANTED import android.content.pm.ResolveInfo import android.database.SQLException import android.graphics.Bitmap -import android.graphics.Color import android.net.ConnectivityManager import android.os.Build -import android.os.Bundle import android.os.PowerManager import android.provider.Settings -import android.view.View import android.view.ViewPropertyAnimator -import android.view.Window +import android.webkit.CookieManager import android.webkit.WebView import androidx.activity.result.ActivityResultLauncher +import androidx.annotation.CheckResult import androidx.annotation.IntegerRes import androidx.appcompat.app.AppCompatActivity import androidx.appcompat.app.AppCompatDelegate import androidx.appcompat.app.AppCompatDialog -import androidx.core.app.ActivityCompat import androidx.core.app.ActivityOptionsCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat -import androidx.core.graphics.ColorUtils import androidx.core.os.LocaleListCompat import androidx.fragment.app.DialogFragment import androidx.fragment.app.Fragment @@ -48,15 +44,8 @@ import androidx.lifecycle.coroutineScope import androidx.webkit.WebViewCompat import androidx.webkit.WebViewFeature import androidx.work.CoroutineWorker -import com.google.android.material.elevation.ElevationOverlayProvider import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.channels.trySendBlocking import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.callbackFlow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flow import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import okio.IOException @@ -69,8 +58,8 @@ import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.xmlpull.v1.XmlPullParser import org.xmlpull.v1.XmlPullParserException import java.io.File +import java.util.concurrent.TimeUnit import kotlin.math.roundToLong -import com.google.android.material.R as materialR val Context.activityManager: ActivityManager? get() = getSystemService(ACTIVITY_SERVICE) as? ActivityManager @@ -86,12 +75,14 @@ suspend fun CoroutineWorker.trySetForeground(): Boolean = runCatchingCancellable setForeground(info) }.isSuccess +@CheckResult fun ActivityResultLauncher.resolve(context: Context, input: I): ResolveInfo? { val pm = context.packageManager val intent = contract.createIntent(context, input) return pm.resolveActivity(intent, 0) } +@CheckResult fun ActivityResultLauncher.tryLaunch( input: I, options: ActivityOptionsCompat? = null, @@ -101,25 +92,6 @@ fun ActivityResultLauncher.tryLaunch( e.printStackTraceDebug() }.isSuccess -fun SharedPreferences.observe(): Flow = callbackFlow { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> - trySendBlocking(key) - } - registerOnSharedPreferenceChangeListener(listener) - awaitClose { - unregisterOnSharedPreferenceChangeListener(listener) - } -} - -fun SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow = flow { - emit(valueProducer()) - observe().collect { upstreamKey -> - if (upstreamKey == key) { - emit(valueProducer()) - } - } -}.distinctUntilChanged() - fun Lifecycle.postDelayed(delay: Long, runnable: Runnable) { coroutineScope.launch { delay(delay) @@ -139,23 +111,6 @@ fun SyncResult.onError(error: Throwable) { error.printStackTraceDebug() } -fun Window.setNavigationBarTransparentCompat(context: Context, elevation: Float, alphaFactor: Float = 0.7f) { - navigationBarColor = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q && - !context.getSystemBoolean("config_navBarNeedsScrim", true) - ) { - Color.TRANSPARENT - } else if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O_MR1) { - val baseColor = context.getThemeColor(android.R.attr.navigationBarColor) - ColorUtils.setAlphaComponent(baseColor, (Color.alpha(baseColor) * alphaFactor).toInt()) - } else { - // Set navbar scrim 70% of navigationBarColor - ElevationOverlayProvider(context).compositeOverlayIfNeeded( - context.getThemeColor(materialR.attr.colorSurfaceContainer, alphaFactor), - elevation, - ) - } -} - val Context.animatorDurationScale: Float get() = Settings.Global.getFloat(this.contentResolver, Settings.Global.ANIMATOR_DURATION_SCALE, 1f) @@ -171,7 +126,7 @@ fun Context.getAnimationDuration(@IntegerRes resId: Int): Long { } fun Context.isLowRamDevice(): Boolean { - return activityManager?.isLowRamDevice ?: false + return activityManager?.isLowRamDevice == true } fun Context.isPowerSaveMode(): Boolean { @@ -185,18 +140,6 @@ val Context.ramAvailable: Long return result.availMem } -fun scaleUpActivityOptionsOf(view: View): Bundle? = if (view.context.isAnimationsEnabled) { - ActivityOptions.makeScaleUpAnimation( - view, - 0, - 0, - view.width, - view.height, - ).toBundle() -} else { - null -} - @SuppressLint("DiscouragedApi") fun Context.getLocalesConfig(): LocaleListCompat { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { @@ -277,6 +220,9 @@ fun WebView.configureForParser(userAgentOverride: String?) = with(settings) { if (userAgentOverride != null) { userAgentString = userAgentOverride } + val cookieManager = CookieManager.getInstance() + cookieManager.setAcceptCookie(true) + cookieManager.setAcceptThirdPartyCookies(this@configureForParser, true) } fun Context.restartApplication() { @@ -285,3 +231,26 @@ fun Context.restartApplication() { startActivity(intent) activity?.finishAndRemoveTask() } + +internal inline fun PowerManager?.withPartialWakeLock(tag: String, body: (PowerManager.WakeLock?) -> R): R { + val wakeLock = newPartialWakeLock(tag) + return try { + wakeLock?.acquire(TimeUnit.HOURS.toMillis(1)) + body(wakeLock) + } finally { + wakeLock?.release() + } +} + +private fun PowerManager?.newPartialWakeLock(tag: String): PowerManager.WakeLock? { + return if (this != null && isWakeLockLevelSupported(PowerManager.PARTIAL_WAKE_LOCK)) { + newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, tag) + } else { + null + } +} + +fun Context.copyToClipboard(label: String, content: String) { + val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager ?: return + clipboardManager.setPrimaryClip(ClipData.newPlainText(label, content)) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt index e89ec9053..ee848731f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Bundle.kt @@ -11,6 +11,7 @@ import androidx.core.content.IntentCompat import androidx.core.os.BundleCompat import androidx.core.os.ParcelCompat import androidx.lifecycle.SavedStateHandle +import org.koitharu.kotatsu.parsers.util.toArraySet import java.io.Serializable import java.util.EnumSet @@ -84,6 +85,14 @@ fun > Parcel.readEnumSet(cls: Class): Set? { return set } +fun Parcel.writeStringSet(set: Set?) { + writeStringArray(set?.toTypedArray().orEmpty()) +} + +fun Parcel.readStringSet(): Set { + return this.createStringArray()?.toArraySet().orEmpty() +} + fun SavedStateHandle.require(key: String): T { return checkNotNull(get(key)) { "Value $key not found in SavedStateHandle or has a wrong type" @@ -110,3 +119,5 @@ fun Parcelable.Creator.unmarshall(bytes: ByteArray): T { parcel.recycle() } } + +inline fun buildBundle(capacity: Int, block: Bundle.() -> Unit): Bundle = Bundle(capacity).apply(block) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt index 6bfbffb75..763df98a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Coil.kt @@ -1,10 +1,10 @@ package org.koitharu.kotatsu.core.util.ext import android.content.Context -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.widget.ImageView import androidx.core.graphics.ColorUtils +import androidx.core.graphics.drawable.toDrawable import androidx.lifecycle.LifecycleOwner import coil3.Extras import coil3.ImageLoader @@ -116,8 +116,8 @@ fun ImageRequest.Builder.defaultPlaceholders(context: Context): ImageRequest.Bui 0.25f, ) return placeholder(AnimatedPlaceholderDrawable(context)) - .fallback(ColorDrawable(context.getThemeColor(materialR.attr.colorSurfaceContainer))) - .error(ColorDrawable(errorColor)) + .fallback(context.getThemeColor(materialR.attr.colorSurfaceContainer).toDrawable()) + .error(errorColor.toDrawable()) } private fun ImageView.ScaleType.toCoilScale(): Scale = if (this == ImageView.ScaleType.CENTER_CROP) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt index a677c9409..c175c1e83 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Collections.kt @@ -86,3 +86,7 @@ fun Collection.mapSortedByCount(isDescending: Boolean = true, mapper: } return sorted.map { it.first } } + +fun Collection.indexOfContains(element: CharSequence?, ignoreCase: Boolean): Int = indexOfFirst { x -> + (x == null && element == null) || (x != null && element != null && x.contains(element, ignoreCase)) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt index 883df5b5a..3bba8b0b2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/ContentResolver.kt @@ -1,11 +1,14 @@ package org.koitharu.kotatsu.core.util.ext -import android.annotation.TargetApi +import android.content.ContentResolver import android.content.Context import android.net.Uri import android.os.Build import android.os.storage.StorageManager import android.provider.DocumentsContract +import android.provider.OpenableColumns +import androidx.annotation.RequiresApi +import androidx.core.net.toFile import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.removeSuffix import java.io.File @@ -31,6 +34,21 @@ fun Uri.resolveFile(context: Context): File? { ) } +fun ContentResolver.getFileDisplayName(uri: Uri): String? = runCatching { + if (uri.isFileUri()) { + return@runCatching uri.toFile().name + } + query(uri, null, null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) { + cursor.getString(cursor.getColumnIndexOrThrow(OpenableColumns.DISPLAY_NAME)) + } else { + null + } + } +}.onFailure { e -> + e.printStackTraceDebug() +}.getOrNull() + private fun getVolumePath(volumeId: String, context: Context): String? { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { getVolumePathForAndroid11AndAbove(volumeId, context) @@ -63,7 +81,7 @@ private fun getVolumePathBeforeAndroid11(volumeId: String, context: Context): St it.printStackTraceDebug() }.getOrNull() -@TargetApi(Build.VERSION_CODES.R) +@RequiresApi(Build.VERSION_CODES.R) private fun getVolumePathForAndroid11AndAbove(volumeId: String, context: Context): String? = runCatching { val storageManager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager storageManager.storageVolumes.firstNotNullOfOrNull { volume -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt index dd6e8e8d9..c6ed7a6ad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/File.kt @@ -7,15 +7,13 @@ import android.os.Build import android.os.Environment import android.os.storage.StorageManager import android.provider.OpenableColumns -import android.webkit.MimeTypeMap import androidx.core.database.getStringOrNull import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible -import okhttp3.MediaType -import okhttp3.MediaType.Companion.toMediaTypeOrNull import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.fs.FileSequence +import org.koitharu.kotatsu.core.util.MimeTypes import java.io.BufferedReader import java.io.File import java.nio.file.attribute.BasicFileAttributes @@ -30,9 +28,9 @@ fun File.subdir(name: String) = File(this, name).also { if (!it.exists()) it.mkdirs() } -fun File.takeIfReadable() = takeIf { it.exists() && it.canRead() } +fun File.takeIfReadable() = takeIf { it.isReadable() } -fun File.takeIfWriteable() = takeIf { it.exists() && it.canWrite() } +fun File.takeIfWriteable() = takeIf { it.isWriteable() } fun File.isNotEmpty() = length() != 0L @@ -41,12 +39,6 @@ fun ZipFile.readText(entry: ZipEntry) = getInputStream(entry).use { output -> output.bufferedReader().use(BufferedReader::readText) } -val ZipEntry.mimeType: MediaType? - get() { - val ext = name.substringAfterLast('.') - return MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)?.toMediaTypeOrNull() - } - fun File.getStorageName(context: Context): String = runCatching { val manager = context.getSystemService(Context.STORAGE_SERVICE) as StorageManager if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { @@ -115,3 +107,14 @@ fun File.walkCompat(includeDirectories: Boolean): Sequence = if (Build.VER val walk = walk() if (includeDirectories) walk else walk.filter { it.isFile } } + +val File.normalizedExtension: String? + get() = MimeTypes.getNormalizedExtension(name) + +fun File.isReadable() = runCatching { + canRead() +}.getOrDefault(false) + +fun File.isWriteable() = runCatching { + canWrite() +}.getOrDefault(false) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt index cd4a04054..214f3b349 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Flow.kt @@ -1,12 +1,15 @@ package org.koitharu.kotatsu.core.util.ext import android.os.SystemClock +import kotlinx.coroutines.channels.SendChannel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.emitAll import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flatMapLatest @@ -17,6 +20,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.transform import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.flow.transformWhile +import kotlinx.coroutines.flow.update import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.suspendlazy.SuspendLazy import java.util.concurrent.TimeUnit @@ -135,3 +139,18 @@ suspend fun Flow.firstNotNullOrNull(): T? = firstOrNull { x -> x ! fun Flow>.flattenLatest() = flatMapLatest { it } fun SuspendLazy.asFlow() = flow { emit(runCatchingCancellable { get() }) } + +suspend fun SendChannel.sendNotNull(item: T?) { + if (item != null) { + send(item) + } +} + +fun MutableStateFlow>.append(item: T) { + update { list -> list + item } +} + +fun Flow.concat(other: Flow) = flow { + emitAll(this@concat) + emitAll(other) +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt index 6b7bde9a5..483d41b5e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Fragment.kt @@ -2,9 +2,9 @@ package org.koitharu.kotatsu.core.util.ext import android.os.Bundle import androidx.core.view.MenuProvider -import androidx.fragment.app.DialogFragment +import androidx.core.view.ancestors import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager +import androidx.fragment.app.FragmentContainerView import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope @@ -18,36 +18,10 @@ inline fun T.withArgs(size: Int, block: Bundle.() -> Unit): T { val Fragment.viewLifecycleScope inline get() = viewLifecycleOwner.lifecycle.coroutineScope -fun DialogFragment.showAllowStateLoss(manager: FragmentManager, tag: String?) { - if (!manager.isStateSaved) { - show(manager, tag) - } -} - fun Fragment.addMenuProvider(provider: MenuProvider) { requireActivity().addMenuProvider(provider, viewLifecycleOwner, Lifecycle.State.RESUMED) } -fun DialogFragment.showDistinct(fm: FragmentManager, tag: String) { - val existing = fm.findFragmentByTag(tag) as? DialogFragment? - if (existing != null && existing.isVisible && existing.arguments == this.arguments) { - return - } - show(fm, tag) -} - -tailrec fun Fragment.dismissParentDialog(): Boolean { - return when (val parent = parentFragment) { - null -> return false - is DialogFragment -> { - parent.dismiss() - true - } - - else -> parent.dismissParentDialog() - } -} - @Suppress("UNCHECKED_CAST") tailrec fun Fragment.findParentCallback(cls: Class): T? { val parent = parentFragment @@ -57,3 +31,8 @@ tailrec fun Fragment.findParentCallback(cls: Class): T? { else -> parent.findParentCallback(cls) } } + +val Fragment.container: FragmentContainerView? + get() = view?.ancestors?.firstNotNullOfOrNull { + it as? FragmentContainerView // TODO check if direct parent + } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt index 2a9f0b81c..9464303f0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Graphics.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.core.util.ext +import android.content.res.ColorStateList import android.graphics.Bitmap import android.graphics.Rect import kotlin.math.roundToInt @@ -18,3 +19,7 @@ inline fun Bitmap.use(block: (Bitmap) -> R) = try { } finally { recycle() } + +fun ColorStateList.hasFocusStateSpecified(): Boolean { + return getColorForState(intArrayOf(android.R.attr.state_focused), defaultColor) != defaultColor +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Insets.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Insets.kt index eef3a3b45..58611fd60 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Insets.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Insets.kt @@ -2,6 +2,8 @@ package org.koitharu.kotatsu.core.util.ext import android.view.View import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.WindowInsetsCompat.Type.InsetsType fun Insets.end(view: View): Int { return if (view.isRtl) left else right @@ -10,3 +12,78 @@ fun Insets.end(view: View): Int { fun Insets.start(view: View): Int { return if (view.isRtl) right else left } + +@Deprecated("") +val WindowInsetsCompat.systemBarsInsets: Insets + get() = getInsets(WindowInsetsCompat.Type.systemBars()) + +@Deprecated("") +fun WindowInsetsCompat.consumeSystemBarsInsets( + left: Boolean = false, + top: Boolean = false, + right: Boolean = false, + bottom: Boolean = false, +): WindowInsetsCompat { + val barsInsets = systemBarsInsets + val insets = Insets.of( + if (left) 0 else barsInsets.left, + if (top) 0 else barsInsets.top, + if (right) 0 else barsInsets.right, + if (bottom) 0 else barsInsets.bottom, + ) + return WindowInsetsCompat.Builder(this) + .setInsets(WindowInsetsCompat.Type.systemBars(), insets) + .build() +} + +fun WindowInsetsCompat.consume( + v: View, + @InsetsType typeMask: Int, + start: Boolean = false, + top: Boolean = false, + end: Boolean = false, + bottom: Boolean = false, +): WindowInsetsCompat { + val insets = getInsets(typeMask) + val newInsets = Insets.of( + /* left = */ if (if (v.isRtl) end else start) 0 else insets.left, + /* top = */ if (top) 0 else insets.top, + /* right = */ if (if (v.isRtl) start else end) 0 else insets.right, + /* bottom = */ if (bottom) 0 else insets.bottom, + ) + return WindowInsetsCompat.Builder(this) + .setInsets(typeMask, newInsets) + .build() +} + +fun WindowInsetsCompat.consumeAll( + @InsetsType typeMask: Int, +): WindowInsetsCompat = WindowInsetsCompat.Builder(this) + .setInsets(typeMask, Insets.NONE) + .build() + +@Deprecated("") +fun WindowInsetsCompat.consumeSystemBarsInsets( + view: View, + start: Boolean = false, + top: Boolean = false, + end: Boolean = false, + bottom: Boolean = false, +): WindowInsetsCompat = consume(view, WindowInsetsCompat.Type.systemBars(), start, top, end, bottom) + +@Deprecated("") +fun WindowInsetsCompat.consumeAllSystemBarsInsets() = consumeAll(WindowInsetsCompat.Type.systemBars()) + +@Deprecated("") +fun Insets.consume( + view: View, + start: Boolean = false, + top: Boolean = false, + end: Boolean = false, + bottom: Boolean = false, +): Insets = Insets.of( + /* left = */ if (if (view.isRtl) end else start) 0 else this.left, + /* top = */ if (top) 0 else this.top, + /* right = */ if (if (view.isRtl) start else end) 0 else this.right, + /* bottom = */ if (bottom) 0 else this.bottom, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/MimeType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/MimeType.kt new file mode 100644 index 000000000..be3a09f22 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/MimeType.kt @@ -0,0 +1,33 @@ +package org.koitharu.kotatsu.core.util.ext + +import okhttp3.MediaType + +private const val TYPE_IMAGE = "image" +private val REGEX_MIME = Regex("^\\w+/([-+.\\w]+|\\*)$", RegexOption.IGNORE_CASE) + +@JvmInline +value class MimeType(private val value: String) { + + val type: String? + get() = value.substringBefore('/', "").takeIfSpecified() + + val subtype: String? + get() = value.substringAfterLast('/', "").takeIfSpecified() + + private fun String.takeIfSpecified(): String? = takeUnless { + it.isEmpty() || it == "*" + } + + override fun toString(): String = value +} + +fun MediaType.toMimeType(): MimeType = MimeType("$type/$subtype") + +fun String.toMimeTypeOrNull(): MimeType? = if (REGEX_MIME.matches(this)) { + MimeType(lowercase()) +} else { + null +} + +val MimeType.isImage: Boolean + get() = type == TYPE_IMAGE diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt index baf078b7f..c859f3074 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Other.kt @@ -1,5 +1,8 @@ package org.koitharu.kotatsu.core.util.ext +import org.koitharu.kotatsu.core.io.NullOutputStream +import java.io.ObjectOutputStream + @Suppress("UNCHECKED_CAST") fun Class.castOrNull(obj: Any?): T? { if (obj == null || !isInstance(obj)) { @@ -7,3 +10,9 @@ fun Class.castOrNull(obj: Any?): T? { } return obj as T } + +fun Any.isSerializable() = runCatching { + val oos = ObjectOutputStream(NullOutputStream()) + oos.writeObject(this) + oos.flush() +}.isSuccess diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Preferences.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Preferences.kt index 72b7fc3bd..b48ca8488 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Preferences.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Preferences.kt @@ -1,7 +1,16 @@ package org.koitharu.kotatsu.core.util.ext import android.content.SharedPreferences +import androidx.collection.ArraySet import androidx.preference.ListPreference +import androidx.preference.MultiSelectListPreference +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.channels.trySendBlocking +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flow +import org.json.JSONArray fun ListPreference.setDefaultValueCompat(defaultValue: String) { if (value == null) { @@ -9,6 +18,10 @@ fun ListPreference.setDefaultValueCompat(defaultValue: String) { } } +fun MultiSelectListPreference.setDefaultValueCompat(defaultValue: Set) { + setDefaultValue(defaultValue) // FIXME not working +} + fun > SharedPreferences.getEnumValue(key: String, enumClass: Class): E? { val stringValue = getString(key, null) ?: return null return enumClass.enumConstants?.find { @@ -23,3 +36,44 @@ fun > SharedPreferences.getEnumValue(key: String, defaultValue: E): fun > SharedPreferences.Editor.putEnumValue(key: String, value: E?) { putString(key, value?.name) } + +fun SharedPreferences.observe(): Flow = callbackFlow { + val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + trySendBlocking(key) + } + registerOnSharedPreferenceChangeListener(listener) + awaitClose { + unregisterOnSharedPreferenceChangeListener(listener) + } +} + +fun SharedPreferences.observe(key: String, valueProducer: suspend () -> T): Flow = flow { + emit(valueProducer()) + observe().collect { upstreamKey -> + if (upstreamKey == key) { + emit(valueProducer()) + } + } +}.distinctUntilChanged() + +fun SharedPreferences.Editor.putAll(values: Map) { + values.forEach { e -> + when (val v = e.value) { + is Boolean -> putBoolean(e.key, v) + is Int -> putInt(e.key, v) + is Long -> putLong(e.key, v) + is Float -> putFloat(e.key, v) + is String -> putString(e.key, v) + is JSONArray -> putStringSet(e.key, v.toStringSet()) + } + } +} + +private fun JSONArray.toStringSet(): Set { + val len = length() + val result = ArraySet(len) + for (i in 0 until len) { + result.add(getString(i)) + } + return result +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt index e2170048f..44c6bcb6d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/RecyclerView.kt @@ -1,6 +1,10 @@ package org.koitharu.kotatsu.core.util.ext +import android.util.DisplayMetrics +import androidx.core.view.doOnNextLayout +import androidx.core.view.isEmpty import androidx.recyclerview.widget.LinearLayoutManager +import androidx.recyclerview.widget.LinearSmoothScroller import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.StaggeredGridLayoutManager import com.hannesdorfmann.adapterdelegates4.dsl.AdapterDelegateViewBindingViewHolder @@ -52,7 +56,7 @@ fun RecyclerView.ViewHolder.getItem(clazz: Class): T? { val RecyclerView.isScrolledToTop: Boolean get() { - if (childCount == 0) { + if (isEmpty()) { return true } val holder = findViewHolderForAdapterPosition(0) @@ -72,3 +76,35 @@ val RecyclerView.LayoutManager?.isLayoutReversed is StaggeredGridLayoutManager -> reverseLayout else -> false } + +// https://medium.com/flat-pack-tech/quickly-scroll-to-the-top-of-a-recyclerview-da15b717f3c4 +fun RecyclerView.smoothScrollToTop() { + val layoutManager = layoutManager as? LinearLayoutManager ?: return + + if (!context.isAnimationsEnabled) { + layoutManager.scrollToPositionWithOffset(0, 0) + return + } + + val smoothScroller = object : LinearSmoothScroller(context) { + init { + targetPosition = 0 + } + + override fun calculateSpeedPerPixel(displayMetrics: DisplayMetrics?) = + super.calculateSpeedPerPixel(displayMetrics) / DEFAULT_SPEED_FACTOR + } + + val jumpBeforeScroll = layoutManager.findFirstVisibleItemPosition() > DEFAULT_JUMP_THRESHOLD + if (jumpBeforeScroll) { + layoutManager.scrollToPositionWithOffset(DEFAULT_JUMP_THRESHOLD, 0) + doOnNextLayout { + layoutManager.startSmoothScroll(smoothScroller) + } + } else { + layoutManager.startSmoothScroll(smoothScroller) + } +} + +private const val DEFAULT_JUMP_THRESHOLD = 30 +private const val DEFAULT_SPEED_FACTOR = 1f diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt index 4cfa36ecf..c62c597cb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Resources.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.core.util.ext import android.annotation.SuppressLint import android.content.Context import android.content.res.Resources +import androidx.annotation.PluralsRes import androidx.annotation.Px import androidx.core.util.TypedValueCompat import kotlin.math.roundToInt @@ -25,3 +26,11 @@ fun Context.getSystemBoolean(resName: String, fallback: Boolean): Boolean { fallback } } + +fun Resources.getQuantityStringSafe(@PluralsRes resId: Int, quantity: Int, vararg formatArgs: Any): String = try { + getQuantityString(resId, quantity, *formatArgs) +} catch (e: Resources.NotFoundException) { + e.report(silent = true) + e.printStackTraceDebug() + formatArgs.firstOrNull()?.toString() ?: quantity.toString() +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt index 8e3acc264..6e5975d19 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/String.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.core.util.ext import android.content.Context -import android.database.DatabaseUtils import androidx.collection.arraySetOf import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.ellipsize @@ -70,11 +69,4 @@ fun Collection.joinToStringWithLimit(context: Context, limit: Int, transf } } -@Deprecated( - "", - ReplaceWith( - "sqlEscapeString(this)", - "android.database.DatabaseUtils.sqlEscapeString", - ), -) -fun String.sqlEscape(): String = DatabaseUtils.sqlEscapeString(this) +fun String.isHttpUrl() = startsWith("https://", ignoreCase = true) || startsWith("http://", ignoreCase = true) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt index 185c3af4a..b01b62d9d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Theme.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.core.util.ext import android.content.Context +import android.content.res.Configuration +import android.content.res.Resources import android.content.res.TypedArray import android.graphics.Color import android.graphics.drawable.Drawable @@ -12,6 +14,9 @@ import androidx.core.content.ContextCompat import androidx.core.content.res.use import androidx.core.graphics.ColorUtils +val Resources.isNightMode: Boolean + get() = configuration.uiMode and Configuration.UI_MODE_NIGHT_MASK == Configuration.UI_MODE_NIGHT_YES + fun Context.getThemeDrawable( @AttrRes resId: Int, ) = obtainStyledAttributes(intArrayOf(resId)).use { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt index 826d6d94d..ef927b1a8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Throwable.kt @@ -6,12 +6,14 @@ import androidx.annotation.DrawableRes import coil3.network.HttpException import com.davemorrissey.labs.subscaleview.decoder.ImageDecodeException import okhttp3.Response +import okhttp3.internal.http2.StreamResetException import okio.FileNotFoundException import okio.IOException import okio.ProtocolException import org.acra.ktx.sendSilentlyWithAcra import org.acra.ktx.sendWithAcra import org.jsoup.HttpStatusException +import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException import org.koitharu.kotatsu.core.exceptions.CaughtException @@ -27,7 +29,6 @@ import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.WrapperIOException import org.koitharu.kotatsu.core.exceptions.WrongPasswordException import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.io.NullOutputStream import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_LOCALE_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_BOTH_STATES_GENRES_NOT_SUPPORTED import org.koitharu.kotatsu.parsers.ErrorMessages.FILTER_MULTIPLE_GENRES_NOT_SUPPORTED @@ -40,7 +41,7 @@ import org.koitharu.kotatsu.parsers.exception.ParseException import org.koitharu.kotatsu.parsers.exception.TooManyRequestExceptions import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import org.koitharu.kotatsu.scrobbling.common.domain.ScrobblerAuthRequiredException -import java.io.ObjectOutputStream +import java.io.File import java.net.ConnectException import java.net.NoRouteToHostException import java.net.SocketException @@ -52,6 +53,8 @@ private const val MSG_NO_SPACE_LEFT = "No space left on device" private const val MSG_CONNECTION_RESET = "Connection reset" private const val IMAGE_FORMAT_NOT_SUPPORTED = "Image format not supported" +private val FNFE_MESSAGE_REGEX = Regex("^(/[^\\s:]+)?.+?\\s([A-Z]{2,6})?\\s.+$") + fun Throwable.getDisplayMessage(resources: Resources): String = getDisplayMessageOrNull(resources) ?: resources.getString(R.string.error_occurred) @@ -86,7 +89,7 @@ private fun Throwable.getDisplayMessageOrNull(resources: Resources): String? = w is UnsupportedFileException -> resources.getString(R.string.text_file_not_supported) is BadBackupFormatException -> resources.getString(R.string.unsupported_backup_message) - is FileNotFoundException -> resources.getString(R.string.file_not_found) + is FileNotFoundException -> parseMessage(resources) ?: message is AccessDeniedException -> resources.getString(R.string.no_access_to_file) is EmptyHistoryException -> resources.getString(R.string.history_is_empty) is ProxyConfigException -> resources.getString(R.string.invalid_proxy_configuration) @@ -196,7 +199,6 @@ fun Throwable.isReportable(): Boolean { || this is WrongPasswordException || this is TooManyRequestExceptions || this is HttpStatusException - || this is SocketException ) { return false } @@ -204,15 +206,18 @@ fun Throwable.isReportable(): Boolean { } fun Throwable.isNetworkError(): Boolean { - return this is UnknownHostException || this is SocketTimeoutException + return this is UnknownHostException + || this is SocketTimeoutException + || this is StreamResetException + || this is SocketException } fun Throwable.report(silent: Boolean = false) { val exception = CaughtException(this) - if (silent) { - exception.sendSilentlyWithAcra() - } else { + if (!silent) { exception.sendWithAcra() + } else if (!BuildConfig.DEBUG) { + exception.sendSilentlyWithAcra() } } @@ -224,8 +229,34 @@ fun Throwable.isWebViewUnavailable(): Boolean { @Suppress("FunctionName") fun NoSpaceLeftException() = IOException(MSG_NO_SPACE_LEFT) -fun Throwable.isSerializable() = runCatching { - val oos = ObjectOutputStream(NullOutputStream()) - oos.writeObject(this) - oos.flush() -}.isSuccess +fun FileNotFoundException.getFile(): File? { + val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null + return groups.getOrNull(1)?.let { File(it) } +} + +fun FileNotFoundException.parseMessage(resources: Resources): String? { + /* + Examples: + /storage/0000-0000/Android/media/d1f08350-0c25-460b-8f50-008e49de3873.jpg.tmp: open failed: EROFS (Read-only file system) + /storage/emulated/0/Android/data/org.koitharu.kotatsu/cache/pages/fe06e192fa371e55918980f7a24c91ea.jpg: open failed: ENOENT (No such file or directory) + /storage/0000-0000/Android/data/org.koitharu.kotatsu/files/manga/e57d3af4-216e-48b2-8432-1541d58eea1e.tmp (I/O error) + */ + val groups = FNFE_MESSAGE_REGEX.matchEntire(message ?: return null)?.groupValues ?: return null + val path = groups.getOrNull(1) + val error = groups.getOrNull(2) + val baseMessageIs = when (error) { + "EROFS" -> R.string.no_write_permission_to_file + "ENOENT" -> R.string.file_not_found + else -> return null + } + return if (path.isNullOrEmpty()) { + resources.getString(baseMessageIs) + } else { + resources.getString( + R.string.inline_preference_pattern, + resources.getString(baseMessageIs), + path, + ) + } +} + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt index 4bd885bef..d4c5e5885 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Uri.kt @@ -22,12 +22,12 @@ fun Uri.isNetworkUri() = scheme.let { it == URI_SCHEME_HTTP || it == URI_SCHEME_HTTPS } -fun File.toZipUri(entryPath: String): Uri = Uri.parse("$URI_SCHEME_ZIP://$absolutePath#$entryPath") +fun File.toZipUri(entryPath: String): Uri = "$URI_SCHEME_ZIP://$absolutePath#$entryPath".toUri() fun File.toZipUri(entryPath: Path?): Uri = toZipUri(entryPath?.toString()?.removePrefix(Path.DIRECTORY_SEPARATOR).orEmpty()) -fun String.toUriOrNull() = if (isEmpty()) null else Uri.parse(this) +fun String.toUriOrNull() = if (isEmpty()) null else this.toUri() fun File.toUri(fragment: String?): Uri = toUri().run { if (fragment != null) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt index 1b0afb72a..da05edc0f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/View.kt @@ -11,6 +11,8 @@ import androidx.appcompat.widget.Toolbar import androidx.core.view.children import androidx.core.view.descendants import androidx.core.view.isVisible +import androidx.fragment.app.Fragment +import androidx.fragment.app.FragmentManager import androidx.recyclerview.widget.RecyclerView import androidx.recyclerview.widget.RecyclerView.ViewHolder import androidx.swiperefreshlayout.widget.CircularProgressDrawable @@ -68,6 +70,11 @@ fun ViewPager2.findCurrentViewHolder(): ViewHolder? { return recyclerView?.findViewHolderForAdapterPosition(currentItem) } +fun FragmentManager.findCurrentPagerFragment(pager: ViewPager2): Fragment? { + val currentId = pager.adapter?.getItemId(pager.currentItem) ?: pager.currentItem + return findFragmentByTag("f$currentId") +} + fun View.resetTransformations() { alpha = 1f translationX = 0f diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/MappingIterator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/MappingIterator.kt index 8ac9f9d41..6f5f34a1b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/MappingIterator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/iterator/MappingIterator.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.core.util.iterator -import org.koitharu.kotatsu.R - class MappingIterator( private val upstream: Iterator, private val mapper: (T) -> R, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/Progress.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/Progress.kt new file mode 100644 index 000000000..8fa0dc62e --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/Progress.kt @@ -0,0 +1,55 @@ +package org.koitharu.kotatsu.core.util.progress + +data class Progress( + val progress: Int, + val total: Int, +) : Comparable { + + val percent: Float + get() = if (total == 0) 0f else progress / total.toFloat() + + val isEmpty: Boolean + get() = progress == 0 + + val isFull: Boolean + get() = progress == total + + val isIndeterminate: Boolean + get() = total < 0 + + override fun compareTo(other: Progress): Int = if (total == other.total) { + progress.compareTo(other.progress) + } else { + percent.compareTo(other.percent) + } + + operator fun inc() = if (isFull) { + this + } else { + copy( + progress = progress + 1, + total = total, + ) + } + + operator fun dec() = if (isEmpty) { + this + } else { + copy( + progress = progress - 1, + total = total, + ) + } + + operator fun plus(child: Progress) = Progress( + progress = progress * child.total + child.progress, + total = total * child.total, + ) + + fun percentSting() = (percent * 100f).toInt().toString() + + companion object { + + val INDETERMINATE = Progress(0, -1) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt index 98515cbbb..66cd93c3e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/zip/ZipOutput.kt @@ -2,7 +2,6 @@ package org.koitharu.kotatsu.core.zip import androidx.annotation.WorkerThread import androidx.collection.ArraySet -import okhttp3.internal.closeQuietly import okio.Closeable import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug @@ -14,8 +13,6 @@ import java.util.zip.Deflater import java.util.zip.ZipEntry import java.util.zip.ZipFile import java.util.zip.ZipOutputStream -import kotlin.contracts.InvocationKind -import kotlin.contracts.contract class ZipOutput( val file: File, @@ -81,11 +78,7 @@ class ZipOutput( @Synchronized override fun close() { - try { - cachedOutput?.close() - } catch (e: NullPointerException) { - e.printStackTraceDebug() - } + cachedOutput?.closeSafe() cachedOutput = null } @@ -141,14 +134,12 @@ class ZipOutput( @Synchronized private fun withOutput(block: (ZipOutputStream) -> T): T { - contract { - callsInPlace(block, InvocationKind.AT_LEAST_ONCE) - } return try { (cachedOutput ?: newOutput(append)).withOutputImpl(block).also { append = true // after 1st success write } } catch (e: NullPointerException) { // probably NullPointerException: Deflater has been closed + e.printStackTraceDebug() newOutput(append).withOutputImpl(block) } } @@ -161,7 +152,16 @@ class ZipOutput( private fun newOutput(append: Boolean) = ZipOutputStream(FileOutputStream(file, append)).also { it.setLevel(compressionLevel) - cachedOutput?.closeQuietly() + cachedOutput?.closeSafe() cachedOutput = it } + + private fun Closeable.closeSafe() { + try { + cachedOutput?.close() + } catch (e: NullPointerException) { + // Don't throw the "Deflater has been closed" exception + e.printStackTraceDebug() + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt index 30c814b96..8e7f25864 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/data/MangaDetails.kt @@ -4,6 +4,8 @@ import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty +import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.reader.data.filterChapters data class MangaDetails( @@ -29,6 +31,12 @@ data class MangaDetails( val local: LocalManga? get() = localManga ?: if (manga.isLocal) LocalManga(manga) else null + val coverUrl: String? + get() = manga.largeCoverUrl + .ifNullOrEmpty { manga.coverUrl } + .ifNullOrEmpty { localManga?.manga?.coverUrl } + ?.nullIfEmpty() + fun toManga() = manga fun filterChapters(branch: String?) = MangaDetails( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/data/ReadingTime.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/data/ReadingTime.kt index aa9c48bf4..bcc56c06d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/data/ReadingTime.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/data/ReadingTime.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.details.data import android.content.res.Resources import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe data class ReadingTime( val minutes: Int, @@ -11,12 +12,12 @@ data class ReadingTime( fun format(resources: Resources): String = when { hours == 0 && minutes == 0 -> resources.getString(R.string.less_than_minute) - hours == 0 -> resources.getQuantityString(R.plurals.minutes, minutes, minutes) - minutes == 0 -> resources.getQuantityString(R.plurals.hours, hours, hours) + hours == 0 -> resources.getQuantityStringSafe(R.plurals.minutes, minutes, minutes) + minutes == 0 -> resources.getQuantityStringSafe(R.plurals.hours, hours, hours) else -> resources.getString( R.string.remaining_time_pattern, - resources.getQuantityString(R.plurals.hours, hours, hours), - resources.getQuantityString(R.plurals.minutes, minutes, minutes), + resources.getQuantityStringSafe(R.plurals.hours, hours, hours), + resources.getQuantityStringSafe(R.plurals.minutes, minutes, minutes), ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/BranchComparator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/BranchComparator.kt index 4d93a464c..e891b547f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/BranchComparator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/BranchComparator.kt @@ -1,8 +1,11 @@ package org.koitharu.kotatsu.details.domain +import org.koitharu.kotatsu.core.util.LocaleStringComparator import org.koitharu.kotatsu.details.ui.model.MangaBranch class BranchComparator : Comparator { - override fun compare(o1: MangaBranch, o2: MangaBranch): Int = compareValues(o1.name, o2.name) + private val delegate = LocaleStringComparator() + + override fun compare(o1: MangaBranch, o2: MangaBranch): Int = delegate.compare(o1.name, o2.name) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt index ff73e6105..c35fae7d1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/DetailsLoadUseCase.kt @@ -6,6 +6,7 @@ import android.text.Spanned import android.text.style.ForegroundColorSpan import androidx.core.text.getSpans import androidx.core.text.parseAsHtml +import coil3.request.CachePolicy import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.async import kotlinx.coroutines.flow.Flow @@ -14,8 +15,9 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runInterruptible import okio.IOException import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.nav.MangaIntent +import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.util.ext.peek import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug @@ -40,10 +42,17 @@ class DetailsLoadUseCase @Inject constructor( private val newChaptersUseCaseProvider: Provider, ) { - operator fun invoke(intent: MangaIntent): Flow = channelFlow { + operator fun invoke(intent: MangaIntent, force: Boolean): Flow = channelFlow { val manga = requireNotNull(mangaDataRepository.resolveIntent(intent)) { "Cannot resolve intent $intent" + }.let { m -> + if (m.chapters.isNullOrEmpty()) { + getCachedDetails(m.id) ?: m + } else { + m + } } + send(MangaDetails(manga, null, null, false)) val local = if (!manga.isLocal) { async { localMangaRepository.findSavedManga(manga) @@ -51,9 +60,9 @@ class DetailsLoadUseCase @Inject constructor( } else { null } - send(MangaDetails(manga, null, null, false)) try { - val details = getDetails(manga) + val details = getDetails(manga, force) + launch { mangaDataRepository.updateChapters(details) } launch { updateTracker(details) } send( MangaDetails( @@ -85,9 +94,13 @@ class DetailsLoadUseCase @Inject constructor( } } - private suspend fun getDetails(seed: Manga) = runCatchingCancellable { + private suspend fun getDetails(seed: Manga, force: Boolean) = runCatchingCancellable { val repository = mangaRepositoryFactory.create(seed.source) - repository.getDetails(seed) + if (repository is CachingMangaRepository) { + repository.getDetails(seed, if (force) CachePolicy.WRITE_ONLY else CachePolicy.ENABLED) + } else { + repository.getDetails(seed) + } }.recoverNotNull { e -> if (e is NotFoundException) { recoverUseCase(seed) @@ -122,4 +135,8 @@ class DetailsLoadUseCase @Inject constructor( }.onFailure { e -> e.printStackTraceDebug() } + + private suspend fun getCachedDetails(mangaId: Long): Manga? = runCatchingCancellable { + mangaDataRepository.findMangaById(mangaId, withChapters = true) + }.getOrNull() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/ReadingTimeUseCase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/ReadingTimeUseCase.kt index 893d70677..e8e211b36 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/ReadingTimeUseCase.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/domain/ReadingTimeUseCase.kt @@ -15,7 +15,7 @@ class ReadingTimeUseCase @Inject constructor( private val statsRepository: StatsRepository, ) { - suspend fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? { + suspend operator fun invoke(manga: MangaDetails?, branch: String?, history: MangaHistory?): ReadingTime? { if (!settings.isReadingTimeEstimationEnabled) { return null } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt index 732c5af0e..5042f95fa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ChaptersMapper.kt @@ -67,7 +67,7 @@ fun MangaDetails.mapChapters( return result } -fun List.withVolumeHeaders(context: Context): List { +fun List.withVolumeHeaders(context: Context): MutableList { var prevVolume = 0 val result = ArrayList((size * 1.4).toInt()) for (item in this) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt index 4578a164a..b3349f74e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsActivity.kt @@ -1,31 +1,22 @@ package org.koitharu.kotatsu.details.ui import android.content.Context -import android.content.Intent -import android.graphics.Color import android.os.Bundle -import android.text.style.DynamicDrawableSpan -import android.text.style.ForegroundColorSpan -import android.text.style.ImageSpan -import android.text.style.RelativeSizeSpan -import android.transition.TransitionManager -import android.view.Menu -import android.view.MenuItem +import android.view.Gravity import android.view.View -import android.view.ViewGroup.MarginLayoutParams +import android.view.ViewGroup import android.view.ViewTreeObserver import android.widget.Toast import androidx.activity.viewModels -import androidx.appcompat.widget.PopupMenu -import androidx.core.graphics.Insets -import androidx.core.text.buildSpannedString -import androidx.core.text.inSpans import androidx.core.text.method.LinkMovementMethodCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding +import androidx.core.view.updatePaddingRelative import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import androidx.transition.TransitionManager import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.SuccessResult @@ -37,11 +28,12 @@ import coil3.request.lifecycle import coil3.request.placeholder import coil3.request.target import coil3.request.transformations +import coil3.size.Precision import coil3.size.Scale import coil3.transform.RoundedCornersTransformation import coil3.util.CoilUtils +import com.google.android.material.bottomsheet.BottomSheetBehavior import com.google.android.material.chip.Chip -import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.FlowCollector @@ -55,79 +47,75 @@ import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.getTitle -import org.koitharu.kotatsu.core.model.iconResId -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.nav.ReaderIntent +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager -import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.parser.favicon.faviconUri import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseListAdapter -import org.koitharu.kotatsu.core.ui.OnContextClickListenerCompat -import org.koitharu.kotatsu.core.ui.image.ChipIconTarget +import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver +import org.koitharu.kotatsu.core.ui.image.FaviconDrawable +import org.koitharu.kotatsu.core.ui.image.TextDrawable +import org.koitharu.kotatsu.core.ui.image.TextViewTarget import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.sheet.BottomSheetCollapseCallback import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.LocaleUtils +import org.koitharu.kotatsu.core.util.ext.consume +import org.koitharu.kotatsu.core.util.ext.copyToClipboard import org.koitharu.kotatsu.core.util.ext.crossfade import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.drawable +import org.koitharu.kotatsu.core.util.ext.drawableStart +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe +import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isTextTruncated import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.parentView -import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf -import org.koitharu.kotatsu.core.util.ext.setNavigationBarTransparentCompat -import org.koitharu.kotatsu.core.util.ext.setOnContextClickListenerCompat +import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivityDetailsBinding +import org.koitharu.kotatsu.databinding.LayoutDetailsTableBinding import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.data.ReadingTime import org.koitharu.kotatsu.details.service.MangaPrefetchService import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.model.HistoryInfo -import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet -import org.koitharu.kotatsu.details.ui.related.RelatedMangaActivity import org.koitharu.kotatsu.details.ui.scrobbling.ScrobblingItemDecoration import org.koitharu.kotatsu.details.ui.scrobbling.ScrollingInfoAdapter -import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver -import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet -import org.koitharu.kotatsu.image.ui.ImageActivity -import org.koitharu.kotatsu.list.domain.MangaListMapper +import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.mangaGridItemAD import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver -import org.koitharu.kotatsu.local.ui.info.LocalInfoDialog import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.util.ellipsize import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty -import org.koitharu.kotatsu.reader.ui.ReaderActivity +import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo -import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet -import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import javax.inject.Inject +import kotlin.math.roundToInt import com.google.android.material.R as materialR @AndroidEntryPoint class DetailsActivity : BaseActivity(), View.OnClickListener, - View.OnLongClickListener, PopupMenu.OnMenuItemClickListener, View.OnLayoutChangeListener, - ViewTreeObserver.OnDrawListener, ChipsView.OnChipClickListener, OnListItemClickListener, - OnContextClickListenerCompat, SwipeRefreshLayout.OnRefreshListener { + View.OnLayoutChangeListener, ViewTreeObserver.OnDrawListener, + ChipsView.OnChipClickListener, OnListItemClickListener, + SwipeRefreshLayout.OnRefreshListener { @Inject lateinit var shortcutManager: AppShortcutManager @@ -135,35 +123,25 @@ class DetailsActivity : @Inject lateinit var coil: ImageLoader - @Inject - lateinit var listMapper: MangaListMapper - private val viewModel: DetailsViewModel by viewModels() private lateinit var menuProvider: DetailsMenuProvider + private lateinit var infoBinding: LayoutDetailsTableBinding override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityDetailsBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setDisplayShowTitleEnabled(false) - } - viewBinding.buttonRead.setOnClickListener(this) - viewBinding.buttonRead.setOnLongClickListener(this) - viewBinding.buttonRead.setOnContextClickListenerCompat(this) - viewBinding.buttonDownload?.setOnClickListener(this) - viewBinding.infoLayout.chipBranch.setOnClickListener(this) - viewBinding.infoLayout.chipSize.setOnClickListener(this) - viewBinding.infoLayout.chipSource.setOnClickListener(this) - viewBinding.infoLayout.chipFavorite.setOnClickListener(this) - viewBinding.infoLayout.chipAuthor.setOnClickListener(this) - viewBinding.infoLayout.chipTime.setOnClickListener(this) + infoBinding = LayoutDetailsTableBinding.bind(viewBinding.root) + setDisplayHomeAsUp(true, false) + supportActionBar?.setDisplayShowTitleEnabled(false) + viewBinding.chipFavorite.setOnClickListener(this) + infoBinding.textViewLocal.setOnClickListener(this) + infoBinding.textViewAuthor.setOnClickListener(this) + infoBinding.textViewSource.setOnClickListener(this) viewBinding.imageViewCover.setOnClickListener(this) + viewBinding.textViewTitle.setOnClickListener(this) viewBinding.buttonDescriptionMore.setOnClickListener(this) viewBinding.buttonScrobblingMore.setOnClickListener(this) viewBinding.buttonRelatedMore.setOnClickListener(this) - viewBinding.infoLayout.chipSource.setOnClickListener(this) - viewBinding.infoLayout.chipSize.setOnClickListener(this) viewBinding.textViewDescription.addOnLayoutChangeListener(this) viewBinding.swipeRefreshLayout.setOnRefreshListener(this) viewBinding.textViewDescription.viewTreeObserver.addOnDrawListener(this) @@ -171,17 +149,23 @@ class DetailsActivity : viewBinding.chipsTags.onChipClickListener = this TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView) viewBinding.containerBottomSheet?.let { sheet -> + sheet.setOnClickListener(this) + sheet.addOnLayoutChangeListener(this) onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet)) + BottomSheetBehavior.from(sheet).addBottomSheetCallback( + DetailsBottomSheetCallback(viewBinding.swipeRefreshLayout, checkNotNull(viewBinding.navbarDim)), + ) } - TitleExpandListener(viewBinding.textViewTitle).attach() + val appRouter = router viewModel.mangaDetails.filterNotNull().observe(this, ::onMangaUpdated) + viewModel.coverUrl.observe(this, ::loadCover) viewModel.onMangaRemoved.observeEvent(this, ::onMangaRemoved) viewModel.onError - .filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) } + .filterNot { appRouter.isChapterPagesSheetShown() } .observeEvent(this, DetailsErrorObserver(this, viewModel, exceptionResolver)) viewModel.onActionDone - .filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) } + .filterNot { appRouter.isChapterPagesSheetShown() } .observeEvent(this, ReversibleActionObserver(viewBinding.scrollView, null)) combine(viewModel.historyInfo, viewModel.isLoading, ::Pair).observe(this) { onHistoryChanged(it.first, it.second) @@ -190,24 +174,25 @@ class DetailsActivity : viewModel.scrobblingInfo.observe(this, ::onScrobblingInfoChanged) viewModel.localSize.observe(this, ::onLocalSizeChanged) viewModel.relatedManga.observe(this, ::onRelatedMangaChanged) - viewModel.readingTime.observe(this, ::onReadingTimeChanged) - viewModel.selectedBranch.observe(this) { - viewBinding.infoLayout.chipBranch.text = it.ifNullOrEmpty { getString(R.string.system_default) } - } viewModel.favouriteCategories.observe(this, ::onFavoritesChanged) val menuInvalidator = MenuInvalidator(this) viewModel.isStatsAvailable.observe(this, menuInvalidator) viewModel.remoteManga.observe(this, menuInvalidator) + viewModel.tags.observe(this, ::onTagsChanged) viewModel.branches.observe(this) { - viewBinding.infoLayout.chipBranch.isVisible = it.size > 1 || !it.firstOrNull()?.name.isNullOrEmpty() - viewBinding.infoLayout.chipBranch.isCloseIconVisible = it.size > 1 + val branch = it.singleOrNull() + infoBinding.textViewTranslation.textAndVisible = branch?.name + infoBinding.textViewTranslation.drawableStart = branch?.locale?.let { + LocaleUtils.getEmojiFlag(it) + }?.let { + TextDrawable.compound(infoBinding.textViewTranslation, it) + } + infoBinding.textViewTranslationLabel.isVisible = infoBinding.textViewTranslation.isVisible } viewModel.chapters.observe(this, PrefetchObserver(this)) viewModel.onDownloadStarted - .filterNot { ChaptersPagesSheet.isShown(supportFragmentManager) } + .filterNot { appRouter.isChapterPagesSheetShown() } .observeEvent(this, DownloadStartedObserver(viewBinding.scrollView)) - - DownloadDialogFragment.registerCallback(this, viewBinding.scrollView) menuProvider = DetailsMenuProvider( activity = this, viewModel = viewModel, @@ -221,69 +206,43 @@ class DetailsActivity : override fun onClick(v: View) { when (v.id) { - R.id.button_read -> openReader(isIncognitoMode = false) - R.id.chip_branch -> showBranchPopupMenu(v) - R.id.button_download -> { - val manga = viewModel.manga.value ?: return - DownloadDialogFragment.show(supportFragmentManager, listOf(manga)) + R.id.textView_author -> { + val manga = viewModel.manga.value + val author = manga?.author ?: return + router.showAuthorDialog(author, manga.source) } - R.id.chip_author -> { + R.id.textView_source -> { val manga = viewModel.manga.value ?: return - startActivity( - MangaListActivity.newIntent( - context = v.context, - source = manga.source, - filter = MangaListFilter(query = manga.author), - ), - ) + router.openList(manga.source, null, null) } - R.id.chip_source -> { + R.id.textView_local -> { val manga = viewModel.manga.value ?: return - startActivity( - MangaListActivity.newIntent( - context = v.context, - source = manga.source, - filter = null, - ), - ) - } - - R.id.chip_size -> { - val manga = viewModel.manga.value ?: return - LocalInfoDialog.show(supportFragmentManager, manga) + router.showLocalInfoDialog(manga) } R.id.chip_favorite -> { val manga = viewModel.manga.value ?: return - FavoriteSheet.show(supportFragmentManager, manga) - } - - R.id.chip_time -> { - if (viewModel.isStatsAvailable.value) { - val manga = viewModel.manga.value ?: return - MangaStatsSheet.show(supportFragmentManager, manga) - } else { - // TODO - } + router.showFavoriteDialog(manga) } R.id.imageView_cover -> { val manga = viewModel.manga.value ?: return - startActivity( - ImageActivity.newIntent( - v.context, - manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } ?: return, - manga.source, - ), - scaleUpActivityOptionsOf(v), + router.openImage( + url = viewModel.coverUrl.value ?: return, + source = manga.source, + anchor = v, ) } R.id.button_description_more -> { val tv = viewBinding.textViewDescription - TransitionManager.beginDelayedTransition(tv.parentView) + if (tv.context.isAnimationsEnabled) { + tv.parentView?.let { + TransitionManager.beginDelayedTransition(it) + } + } if (tv.maxLines in 1 until Integer.MAX_VALUE) { tv.maxLines = Integer.MAX_VALUE } else { @@ -293,60 +252,34 @@ class DetailsActivity : R.id.button_scrobbling_more -> { val manga = viewModel.manga.value ?: return - ScrobblingSelectorSheet.show(supportFragmentManager, manga, null) + router.showScrobblingSelectorSheet(manga, null) } R.id.button_related_more -> { val manga = viewModel.manga.value ?: return - startActivity(RelatedMangaActivity.newIntent(v.context, manga)) + router.openRelated(manga) } - } - } - override fun onChipClick(chip: Chip, data: Any?) { - val tag = data as? MangaTag ?: return - // TODO dialog - startActivity(MangaListActivity.newIntent(this, tag.source, MangaListFilter(tags = setOf(tag)))) - } - - override fun onContextClick(v: View): Boolean = onLongClick(v) - - override fun onLongClick(v: View): Boolean = when (v.id) { - R.id.button_read -> { - val menu = PopupMenu(v.context, v) - menu.inflate(R.menu.popup_read) - menu.menu.findItem(R.id.action_forget)?.isVisible = viewModel.historyInfo.value.run { - !isIncognitoMode && history != null + R.id.textView_title -> { + val title = viewModel.manga.value?.title?.nullIfEmpty() ?: return + buildAlertDialog(this) { + setMessage(title) + setNegativeButton(R.string.close, null) + setPositiveButton(androidx.preference.R.string.copy) { _, _ -> + copyToClipboard(getString(R.string.content_type_manga), title) + } + }.show() } - menu.setOnMenuItemClickListener(this) - menu.setForceShowIcon(true) - menu.show() - true } - - else -> false } - override fun onMenuItemClick(item: MenuItem): Boolean { - return when (item.itemId) { - R.id.action_incognito -> { - openReader(isIncognitoMode = true) - true - } - - R.id.action_forget -> { - viewModel.removeFromHistory() - true - } - - else -> false - } + override fun onChipClick(chip: Chip, data: Any?) { + val tag = data as? MangaTag ?: return + router.showTagDialog(tag) } override fun onItemClick(item: Bookmark, view: View) { - startActivity( - ReaderActivity.IntentBuilder(view.context).bookmark(item).incognito(true).build(), - ) + router.openReader(ReaderIntent.Builder(view.context).bookmark(item).incognito(true).build()) Toast.makeText(view.context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() } @@ -374,11 +307,43 @@ class DetailsActivity : ) { with(viewBinding) { buttonDescriptionMore.isVisible = textViewDescription.isTextTruncated + containerBottomSheet?.let { sheet -> + val peekHeight = BottomSheetBehavior.from(sheet).peekHeight + if (scrollView.paddingBottom != peekHeight) { + scrollView.updatePadding(bottom = peekHeight) + } + } + } + } + + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeMask) + if (viewBinding.cardChapters != null) { + // landscape + viewBinding.cardChapters?.updateLayoutParams { + topMargin = barsInsets.top + resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer) + marginEnd = barsInsets.end(v) + resources.getDimensionPixelOffset(R.dimen.side_card_offset) + bottomMargin = barsInsets.bottom + resources.getDimensionPixelOffset(R.dimen.side_card_offset) + } + viewBinding.scrollView.updatePaddingRelative( + bottom = barsInsets.bottom, + start = barsInsets.start(v), + ) + viewBinding.appbar.updatePaddingRelative( + start = barsInsets.start(v), + ) + return insets.consume(v, typeMask, bottom = true, end = true) + } else { + viewBinding.navbarDim?.updateLayoutParams { + height = barsInsets.bottom + } + return insets } } private fun onFavoritesChanged(categories: Set) { - val chip = viewBinding.infoLayout.chipFavorite + val chip = viewBinding.chipFavorite chip.setChipIconResource(if (categories.isEmpty()) R.drawable.ic_heart_outline else R.drawable.ic_heart) chip.text = if (categories.isEmpty()) { getString(R.string.add_to_favourites) @@ -387,18 +352,14 @@ class DetailsActivity : } } - private fun onReadingTimeChanged(time: ReadingTime?) { - val chip = viewBinding.infoLayout.chipTime - chip.textAndVisible = time?.formatShort(chip.resources) - } - private fun onLocalSizeChanged(size: Long) { - val chip = viewBinding.infoLayout.chipSize if (size == 0L) { - chip.isVisible = false + infoBinding.textViewLocal.isVisible = false + infoBinding.textViewLocalLabel.isVisible = false } else { - chip.text = FileSize.BYTES.format(chip.context, size) - chip.isVisible = true + infoBinding.textViewLocal.text = FileSize.BYTES.format(this, size) + infoBinding.textViewLocal.isVisible = true + infoBinding.textViewLocalLabel.isVisible = true } } @@ -417,7 +378,7 @@ class DetailsActivity : coil, this, StaticItemSizeResolver(resources.getDimensionPixelSize(R.dimen.smaller_grid_width)), ) { item, view -> - startActivity(newIntent(view.context, item)) + router.openDetails(item) }, ).also { rv.adapter = it } adapter.items = related @@ -434,7 +395,7 @@ class DetailsActivity : if (adapter != null) { adapter.items = scrobblings } else { - adapter = ScrollingInfoAdapter(this, coil, supportFragmentManager) + adapter = ScrollingInfoAdapter(this, coil, router) adapter.items = scrobblings viewBinding.recyclerViewScrobbling.adapter = adapter viewBinding.recyclerViewScrobbling.addItemDecoration(ScrobblingItemDecoration()) @@ -442,62 +403,57 @@ class DetailsActivity : } private fun onMangaUpdated(details: MangaDetails) { + val manga = details.toManga() with(viewBinding) { - val manga = details.toManga() - // Main - loadCover(manga) textViewTitle.text = manga.title - textViewSubtitle.textAndVisible = manga.altTitle - infoLayout.chipAuthor.textAndVisible = manga.author?.ellipsize(AUTHOR_LABEL_LIMIT) + textViewSubtitle.textAndVisible = manga.altTitles.joinToString("\n") + textViewNsfw.isVisible = manga.isNsfw + textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) } + } + with(infoBinding) { + textViewAuthor.textAndVisible = manga.author + textViewAuthorLabel.isVisible = textViewAuthor.isVisible if (manga.hasRating) { - ratingBar.rating = manga.rating * ratingBar.numStars - ratingBar.isVisible = true + ratingBarRating.rating = manga.rating * ratingBarRating.numStars + ratingBarRating.isVisible = true + textViewRatingLabel.isVisible = true } else { - ratingBar.isVisible = false + ratingBarRating.isVisible = false + textViewRatingLabel.isVisible = false } - manga.state?.let { state -> textViewState.textAndVisible = resources.getString(state.titleResId) - imageViewState.setImageResource(state.iconResId) - imageViewState.isVisible = true + textViewStateLabel.isVisible = textViewState.isVisible } ?: run { textViewState.isVisible = false - imageViewState.isVisible = false + textViewStateLabel.isVisible = false } if (manga.source == LocalMangaSource || manga.source == UnknownMangaSource) { - infoLayout.chipSource.isVisible = false + textViewSource.isVisible = false + textViewSourceLabel.isVisible = false } else { - infoLayout.chipSource.text = manga.source.getTitle(this@DetailsActivity) - infoLayout.chipSource.isVisible = true + textViewSource.textAndVisible = manga.source.getTitle(this@DetailsActivity) + textViewSourceLabel.isVisible = textViewSource.isVisible == true } - - textViewNsfw.isVisible = manga.isNsfw - - // Chips - bindTags(manga) - - textViewDescription.text = details.description.ifNullOrEmpty { getString(R.string.no_description) } - - viewBinding.infoLayout.chipSource.also { chip -> - ImageRequest.Builder(this@DetailsActivity) - .data(manga.source.faviconUri()) - .lifecycle(this@DetailsActivity) - .crossfade(false) - .size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size)) - .target(ChipIconTarget(chip)) - .placeholder(R.drawable.ic_web) - .fallback(R.drawable.ic_web) - .error(R.drawable.ic_web) - .mangaSourceExtra(manga.source) - .transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner))) - .allowRgb565(true) - .enqueueWith(coil) - } - - title = manga.title - invalidateOptionsMenu() + val faviconPlaceholderFactory = FaviconDrawable.Factory(R.style.FaviconDrawable_Chip) + ImageRequest.Builder(this@DetailsActivity) + .data(manga.source.faviconUri()) + .lifecycle(this@DetailsActivity) + .crossfade(false) + .precision(Precision.EXACT) + .size(resources.getDimensionPixelSize(materialR.dimen.m3_chip_icon_size)) + .target(TextViewTarget(textViewSource, Gravity.START)) + .placeholder(faviconPlaceholderFactory) + .error(faviconPlaceholderFactory) + .fallback(faviconPlaceholderFactory) + .mangaSourceExtra(manga.source) + .transformations(RoundedCornersTransformation(resources.getDimension(R.dimen.chip_icon_corner))) + .allowRgb565(true) + .enqueueWith(coil) } + title = manga.title + invalidateOptionsMenu() } private fun onMangaRemoved(manga: Manga) { @@ -509,115 +465,43 @@ class DetailsActivity : finishAfterTransition() } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.cardChapters?.updateLayoutParams { - val baseOffset = leftMargin - bottomMargin = insets.bottom + baseOffset - topMargin = insets.bottom + baseOffset - } - viewBinding.scrollView.updatePadding( - bottom = insets.bottom, - ) - viewBinding.containerBottomSheet?.let { bs -> - window.setNavigationBarTransparentCompat(this, bs.elevation, 0.9f) - } - } - - private fun onHistoryChanged(info: HistoryInfo, isLoading: Boolean) = with(viewBinding) { - buttonRead.setTitle(if (info.canContinue) R.string._continue else R.string.read) - buttonRead.subtitle = when { + private fun onHistoryChanged(info: HistoryInfo, isLoading: Boolean) = with(infoBinding) { + textViewChapters.text = when { isLoading -> getString(R.string.loading_) - info.isIncognitoMode -> getString(R.string.incognito_mode) - info.isChapterMissing -> getString(R.string.chapter_is_missing) - info.currentChapter >= 0 -> getString(R.string.chapter_d_of_d, info.currentChapter + 1, info.totalChapters) + info.currentChapter >= 0 -> getString( + R.string.chapter_d_of_d, + info.currentChapter + 1, + info.totalChapters, + ).withEstimatedTime(info.estimatedTime) + info.totalChapters == 0 -> getString(R.string.no_chapters) info.totalChapters == -1 -> getString(R.string.error_occurred) - else -> resources.getQuantityString(R.plurals.chapters, info.totalChapters, info.totalChapters) + else -> resources.getQuantityStringSafe(R.plurals.chapters, info.totalChapters, info.totalChapters) + .withEstimatedTime(info.estimatedTime) } - val isFirstCall = buttonRead.tag == null - buttonRead.tag = Unit - buttonRead.setProgress(info.percent.coerceIn(0f, 1f), !isFirstCall) - buttonDownload?.isEnabled = info.isValid && info.canDownload - buttonRead.isEnabled = info.isValid - } - - private fun showBranchPopupMenu(v: View) { - val branches = viewModel.branches.value - if (branches.size <= 1) { - return - } - val menu = PopupMenu(v.context, v) - for ((i, branch) in branches.withIndex()) { - val title = buildSpannedString { - if (branch.isCurrent) { - inSpans( - ImageSpan( - this@DetailsActivity, - R.drawable.ic_current_chapter, - DynamicDrawableSpan.ALIGN_BASELINE, - ), - ) { - append(' ') - } - append(' ') - } - append(branch.name ?: getString(R.string.system_default)) - append(' ') - append(' ') - inSpans( - ForegroundColorSpan( - v.context.getThemeColor( - android.R.attr.textColorSecondary, - Color.LTGRAY, - ), - ), - RelativeSizeSpan(0.74f), - ) { - append(branch.count.toString()) - } - } - val item = menu.menu.add(R.id.group_branches, Menu.NONE, i, title) - item.isCheckable = true - item.isChecked = branch.isSelected - } - menu.menu.setGroupCheckable(R.id.group_branches, true, true) - menu.setOnMenuItemClickListener { - viewModel.setSelectedBranch(branches.getOrNull(it.order)?.name) - true - } - menu.show() - } - - private fun openReader(isIncognitoMode: Boolean) { - val manga = viewModel.manga.value ?: return - if (viewModel.historyInfo.value.isChapterMissing) { - Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT) - .show() + textViewProgress.textAndVisible = if (info.percent <= 0f) { + null } else { - startActivity( - ReaderActivity.IntentBuilder(this) - .manga(manga) - .branch(viewModel.selectedBranchValue) - .incognito(isIncognitoMode) - .build(), - ) - if (isIncognitoMode) { - Toast.makeText(this, R.string.incognito_mode, Toast.LENGTH_SHORT).show() - } + val displayPercent = if (ReadingProgress.isCompleted(info.percent)) 100 else (info.percent * 100f).toInt() + getString(R.string.percent_string_pattern, displayPercent.toString()) } + + progress.setProgressCompat( + (progress.max * info.percent.coerceIn(0f, 1f)).roundToInt(), + true, + ) + textViewProgressLabel.isVisible = info.history != null + textViewProgress.isVisible = info.history != null + progress.isVisible = info.history != null } - private fun bindTags(manga: Manga) { - viewBinding.chipsTags.isVisible = manga.tags.isNotEmpty() - viewBinding.chipsTags.setChips(listMapper.mapTags(manga.tags)) + private fun onTagsChanged(tags: Collection) { + viewBinding.chipsTags.isVisible = tags.isNotEmpty() + viewBinding.chipsTags.setChips(tags) } - private fun loadCover(manga: Manga) { - val imageUrl = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } + private fun loadCover(imageUrl: String?) { + viewBinding.imageViewCover.isEnabled = !imageUrl.isNullOrEmpty() val lastResult = CoilUtils.result(viewBinding.imageViewCover) if (lastResult is SuccessResult && lastResult.request.data == imageUrl) { return @@ -627,10 +511,9 @@ class DetailsActivity : .size(CoverSizeResolver(viewBinding.imageViewCover)) .scale(Scale.FILL) .data(imageUrl) - .mangaSourceExtra(manga.source) + .mangaSourceExtra(viewModel.getMangaOrNull()?.source) .crossfade(this) .lifecycle(this) - .placeholderMemoryCacheKey(manga.coverUrl) val previousDrawable = lastResult?.drawable if (previousDrawable != null) { request.fallback(previousDrawable) @@ -642,6 +525,14 @@ class DetailsActivity : request.enqueueWith(coil) } + private fun String.withEstimatedTime(time: ReadingTime?): String { + if (time == null) { + return this + } + val timeFormatted = time.formatShort(resources) + return getString(R.string.chapters_time_pattern, this, timeFormatted) + } + private class PrefetchObserver( private val context: Context, ) : FlowCollector?> { @@ -663,16 +554,5 @@ class DetailsActivity : companion object { private const val FAV_LABEL_LIMIT = 16 - private const val AUTHOR_LABEL_LIMIT = 16 - - fun newIntent(context: Context, manga: Manga): Intent { - return Intent(context, DetailsActivity::class.java) - .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) - } - - fun newIntent(context: Context, mangaId: Long): Intent { - return Intent(context, DetailsActivity::class.java) - .putExtra(MangaIntent.KEY_ID, mangaId) - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsBottomSheetCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsBottomSheetCallback.kt new file mode 100644 index 000000000..b431db007 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsBottomSheetCallback.kt @@ -0,0 +1,19 @@ +package org.koitharu.kotatsu.details.ui + +import android.view.View +import androidx.swiperefreshlayout.widget.SwipeRefreshLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior + +class DetailsBottomSheetCallback( + private val swipeRefreshLayout: SwipeRefreshLayout, + private val navbarDimView: View, +) : BottomSheetBehavior.BottomSheetCallback() { + + override fun onStateChanged(bottomSheet: View, newState: Int) { + swipeRefreshLayout.isEnabled = newState == BottomSheetBehavior.STATE_COLLAPSED + } + + override fun onSlide(bottomSheet: View, slideOffset: Float) { + navbarDimView.alpha = 1f - slideOffset.coerceAtLeast(0f) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt index 745703330..0b6616569 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsErrorObserver.kt @@ -5,7 +5,6 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.UnsupportedSourceException import org.koitharu.kotatsu.core.exceptions.resolve.ErrorObserver import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.isNetworkError import org.koitharu.kotatsu.core.util.ext.isSerializable @@ -38,10 +37,10 @@ class DetailsErrorObserver( } value is ParseException -> { - val fm = fragmentManager - if (fm != null && value.isSerializable()) { + val router = router() + if (router != null && value.isSerializable()) { snackbar.setAction(R.string.details) { - ErrorDetailsDialog.show(fm, value, value.url) + router.showErrorDialog(value) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt index 0f7765a87..f86bb404f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsMenuProvider.kt @@ -5,25 +5,17 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import androidx.core.content.pm.ShortcutManagerCompat -import androidx.core.net.toFile -import androidx.core.net.toUri import androidx.core.view.MenuProvider import androidx.fragment.app.FragmentActivity import androidx.lifecycle.lifecycleScope -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.launch import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.alternatives.ui.AlternativesActivity -import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.core.model.LocalMangaSource -import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager -import org.koitharu.kotatsu.core.util.ShareHelper -import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment -import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet -import org.koitharu.kotatsu.search.ui.multi.SearchActivity -import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet +import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog class DetailsMenuProvider( private val activity: FragmentActivity, @@ -32,12 +24,16 @@ class DetailsMenuProvider( private val appShortcutManager: AppShortcutManager, ) : MenuProvider { + private val router: AppRouter + get() = activity.router + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { menuInflater.inflate(R.menu.opt_details, menu) } override fun onPrepareMenu(menu: Menu) { val manga = viewModel.manga.value + menu.findItem(R.id.action_share).isVisible = manga != null && AppRouter.isShareSupported(manga) menu.findItem(R.id.action_save).isVisible = manga?.source != null && manga.source != LocalMangaSource menu.findItem(R.id.action_delete).isVisible = manga?.source == LocalMangaSource menu.findItem(R.id.action_browser).isVisible = manga?.source != LocalMangaSource @@ -49,77 +45,54 @@ class DetailsMenuProvider( } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + val manga = viewModel.getMangaOrNull() ?: return false when (menuItem.itemId) { R.id.action_share -> { - viewModel.manga.value?.let { - val shareHelper = ShareHelper(activity) - if (it.isLocal) { - shareHelper.shareCbz(listOf(it.url.toUri().toFile())) - } else { - shareHelper.shareMangaLink(it) - } - } + router.showShareDialog(manga) } R.id.action_delete -> { - val title = viewModel.manga.value?.title.orEmpty() - MaterialAlertDialogBuilder(activity) - .setTitle(R.string.delete_manga) - .setMessage(activity.getString(R.string.text_delete_local_manga, title)) - .setPositiveButton(R.string.delete) { _, _ -> - viewModel.deleteLocal() - } - .setNegativeButton(android.R.string.cancel, null) - .show() + buildAlertDialog(activity) { + setTitle(R.string.delete_manga) + setMessage(activity.getString(R.string.text_delete_local_manga, manga.title)) + setPositiveButton(R.string.delete) { _, _ -> viewModel.deleteLocal() } + setNegativeButton(android.R.string.cancel, null) + }.show() } R.id.action_save -> { - DownloadDialogFragment.show(activity.supportFragmentManager, listOfNotNull(viewModel.manga.value)) + router.showDownloadDialog(manga, snackbarHost) } R.id.action_browser -> { - viewModel.manga.value?.let { - activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.source, it.title)) - } + router.openBrowser(url = manga.publicUrl, source = manga.source, title = manga.title) } R.id.action_online -> { - viewModel.remoteManga.value?.let { - activity.startActivity(DetailsActivity.newIntent(activity, it)) - } + router.openDetails(manga) } R.id.action_related -> { - viewModel.manga.value?.let { - activity.startActivity(SearchActivity.newIntent(activity, it.title)) - } + router.openSearch(manga.title) } R.id.action_alternatives -> { - viewModel.manga.value?.let { - activity.startActivity(AlternativesActivity.newIntent(activity, it)) - } + router.openAlternatives(manga) } R.id.action_stats -> { - viewModel.manga.value?.let { - MangaStatsSheet.show(activity.supportFragmentManager, it) - } + router.showStatisticSheet(manga) } R.id.action_scrobbling -> { - viewModel.manga.value?.let { - ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null) - } + router.showScrobblingSelectorSheet(manga, null) } R.id.action_shortcut -> { - viewModel.manga.value?.let { - activity.lifecycleScope.launch { - if (!appShortcutManager.requestPinShortcut(it)) { - Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) - .show() - } + activity.lifecycleScope.launch { + if (!appShortcutManager.requestPinShortcut(manga)) { + Snackbar.make(snackbarHost, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) + .show() } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt index 772647591..48ef798b1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsViewModel.kt @@ -21,7 +21,7 @@ import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.model.getPreferredBranch -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.MangaIntent import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.util.ReversibleAction @@ -112,12 +112,14 @@ class DetailsViewModel @Inject constructor( history, interactor.observeIncognitoMode(manga), ) { m, b, h, im -> - HistoryInfo(m, b, h, im) - }.stateIn( - scope = viewModelScope + Dispatchers.Default, - started = SharingStarted.Eagerly, - initialValue = HistoryInfo(null, null, null, false), - ) + val estimatedTime = readingTimeUseCase.invoke(m, b, h) + HistoryInfo(m, b, h, im, estimatedTime) + }.withErrorHandling() + .stateIn( + scope = viewModelScope + Dispatchers.Default, + started = SharingStarted.Eagerly, + initialValue = HistoryInfo(null, null, null, false, null), + ) val localSize = mangaDetails .map { it?.local } @@ -150,6 +152,10 @@ class DetailsViewModel @Inject constructor( } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, emptyList()) + val tags = manga.mapLatest { + mangaListMapper.mapTags(it?.tags.orEmpty()) + }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + val branches: StateFlow> = combine( mangaDetails, selectedBranch, @@ -170,19 +176,11 @@ class DetailsViewModel @Inject constructor( }.sortedWith(BranchComparator()) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) - val readingTime = combine( - mangaDetails, - selectedBranch, - history, - ) { m, b, h -> - readingTimeUseCase.invoke(m, b, h) - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Lazily, null) - val selectedBranchValue: String? get() = selectedBranch.value init { - loadingJob = doLoad() + loadingJob = doLoad(force = false) launchJob(Dispatchers.Default) { val manga = mangaDetails.firstOrNull { !it?.chapters.isNullOrEmpty() } ?: return@launchJob val h = history.firstOrNull() @@ -198,7 +196,7 @@ class DetailsViewModel @Inject constructor( fun reload() { loadingJob.cancel() - loadingJob = doLoad() + loadingJob = doLoad(force = true) } fun updateScrobbling(index: Int, rating: Float, status: ScrobblingStatus?) { @@ -222,14 +220,6 @@ class DetailsViewModel @Inject constructor( } } - fun startChaptersSelection() { - val chapters = chapters.value - val chapter = chapters.find { - it.isUnread && !it.isDownloaded - } ?: chapters.firstOrNull() ?: return - onSelectChapter.call(chapter.chapter.id) - } - fun removeFromHistory() { launchJob(Dispatchers.Default) { val handle = historyRepository.delete(setOf(mangaId)) @@ -237,17 +227,18 @@ class DetailsViewModel @Inject constructor( } } - private fun doLoad() = launchLoadingJob(Dispatchers.Default) { - detailsLoadUseCase.invoke(intent) + private fun doLoad(force: Boolean) = launchLoadingJob(Dispatchers.Default) { + detailsLoadUseCase.invoke(intent, force) .onEachWhile { - if (it.allChapters.isEmpty()) { - return@onEachWhile false + if (it.allChapters.isNotEmpty()) { + val manga = it.toManga() + // find default branch + val hist = historyRepository.getOne(manga) + selectedBranch.value = manga.getPreferredBranch(hist) + true + } else { + false } - val manga = it.toManga() - // find default branch - val hist = historyRepository.getOne(manga) - selectedBranch.value = manga.getPreferredBranch(hist) - true }.collect { mangaDetails.value = it } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ReadButtonDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ReadButtonDelegate.kt new file mode 100644 index 000000000..a93b8e472 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/ReadButtonDelegate.kt @@ -0,0 +1,179 @@ +package org.koitharu.kotatsu.details.ui + +import android.content.Context +import android.graphics.Color +import android.text.style.DynamicDrawableSpan +import android.text.style.ForegroundColorSpan +import android.text.style.ImageSpan +import android.text.style.RelativeSizeSpan +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.Toast +import androidx.appcompat.widget.PopupMenu +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import androidx.core.view.MenuCompat +import androidx.core.view.get +import androidx.lifecycle.LifecycleOwner +import com.google.android.material.button.MaterialButton +import com.google.android.material.button.MaterialSplitButton +import com.google.android.material.snackbar.Snackbar +import kotlinx.coroutines.flow.combine +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.ReaderIntent +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.details.ui.model.HistoryInfo + +class ReadButtonDelegate( + private val splitButton: MaterialSplitButton, + private val viewModel: DetailsViewModel, + private val router: AppRouter, +) : View.OnClickListener, PopupMenu.OnMenuItemClickListener, PopupMenu.OnDismissListener { + + private val buttonRead = splitButton[0] as MaterialButton + private val buttonMenu = splitButton[1] as MaterialButton + + private val context: Context + get() = buttonRead.context + + override fun onClick(v: View) { + when (v.id) { + R.id.button_read -> openReader(isIncognitoMode = false) + R.id.button_read_menu -> showMenu() + } + } + + override fun onMenuItemClick(item: MenuItem): Boolean { + when (item.itemId) { + R.id.action_incognito -> openReader(isIncognitoMode = true) + R.id.action_forget -> viewModel.removeFromHistory() + R.id.action_download -> { + router.showDownloadDialog( + manga = setOf(viewModel.getMangaOrNull() ?: return false), + snackbarHost = splitButton, + ) + } + + Menu.NONE -> { + val branch = viewModel.branches.value.getOrNull(item.order) ?: return false + viewModel.setSelectedBranch(branch.name) + } + + else -> return false + } + return true + } + + override fun onDismiss(menu: PopupMenu?) { + buttonMenu.isChecked = false + } + + fun attach(lifecycleOwner: LifecycleOwner) { + buttonRead.setOnClickListener(this) + buttonMenu.setOnClickListener(this) + combine(viewModel.isLoading, viewModel.historyInfo, ::Pair) + .observe(lifecycleOwner) { (isLoading, historyInfo) -> + onHistoryChanged(isLoading, historyInfo) + } + } + + private fun showMenu() { + val menu = PopupMenu(context, buttonMenu) + menu.inflate(R.menu.popup_read) + prepareMenu(menu.menu) + menu.setOnMenuItemClickListener(this) + menu.setForceShowIcon(true) + menu.setOnDismissListener(this) + if (menu.menu.hasVisibleItems()) { + buttonMenu.isChecked = true + menu.show() + } else { + buttonMenu.isChecked = false + } + } + + private fun prepareMenu(menu: Menu) { + MenuCompat.setGroupDividerEnabled(menu, true) + menu.populateBranchList() + val history = viewModel.historyInfo.value + menu.findItem(R.id.action_incognito)?.isVisible = !history.isIncognitoMode + menu.findItem(R.id.action_forget)?.isVisible = history.history != null + menu.findItem(R.id.action_download)?.isVisible = viewModel.getMangaOrNull()?.isLocal == false + } + + private fun openReader(isIncognitoMode: Boolean) { + val manga = viewModel.manga.value ?: return + if (viewModel.historyInfo.value.isChapterMissing) { + Snackbar.make(buttonRead, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT) + .show() // TODO + } else { + router.openReader( + ReaderIntent.Builder(context) + .manga(manga) + .branch(viewModel.selectedBranchValue) + .incognito(isIncognitoMode) + .build(), + ) + if (isIncognitoMode) { + Toast.makeText(context, R.string.incognito_mode, Toast.LENGTH_SHORT).show() + } + } + } + + private fun onHistoryChanged(isLoading: Boolean, info: HistoryInfo) { + buttonRead.setText( + when { + isLoading -> R.string.loading_ + info.isIncognitoMode -> R.string.incognito + info.canContinue -> R.string._continue + else -> R.string.read + }, + ) + splitButton.isEnabled = !isLoading && info.isValid + } + + private fun Menu.populateBranchList() { + val branches = viewModel.branches.value + if (branches.size <= 1) { + return + } + for ((i, branch) in branches.withIndex()) { + val title = buildSpannedString { + if (branch.isCurrent) { + inSpans( + ImageSpan( + context, + R.drawable.ic_current_chapter, + DynamicDrawableSpan.ALIGN_BASELINE, + ), + ) { + append(' ') + } + append(' ') + } + append(branch.name ?: context.getString(R.string.system_default)) + append(' ') + append(' ') + inSpans( + ForegroundColorSpan( + context.getThemeColor( + android.R.attr.textColorSecondary, + Color.LTGRAY, + ), + ), + RelativeSizeSpan(0.74f), + ) { + append(branch.count.toString()) + } + } + val item = add(R.id.group_branches, Menu.NONE, i, title) + item.isCheckable = true + item.isChecked = branch.isSelected + } + setGroupCheckable(R.id.group_branches, true, true) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleExpandListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleExpandListener.kt deleted file mode 100644 index 5d5765100..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/TitleExpandListener.kt +++ /dev/null @@ -1,45 +0,0 @@ -package org.koitharu.kotatsu.details.ui - -import android.annotation.SuppressLint -import android.transition.TransitionManager -import android.view.GestureDetector -import android.view.MotionEvent -import android.view.View -import android.view.View.OnTouchListener -import android.view.ViewGroup -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.widgets.SelectableTextView -import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled - -@SuppressLint("ClickableViewAccessibility") -class TitleExpandListener( - private val textView: SelectableTextView, -) : GestureDetector.SimpleOnGestureListener(), OnTouchListener { - - private val gestureDetector = GestureDetector(textView.context, this) - private val linesExpanded = textView.resources.getInteger(R.integer.details_description_lines) - private val linesCollapsed = textView.resources.getInteger(R.integer.details_title_lines) - - override fun onTouch(v: View?, event: MotionEvent) = gestureDetector.onTouchEvent(event) - - override fun onSingleTapConfirmed(e: MotionEvent): Boolean { - if (textView.context.isAnimationsEnabled) { - TransitionManager.beginDelayedTransition(textView.parent as ViewGroup) - } - if (textView.maxLines in 1 until Integer.MAX_VALUE) { - textView.maxLines = Integer.MAX_VALUE - } else { - textView.maxLines = linesCollapsed - } - return true - } - - override fun onLongPress(e: MotionEvent) { - textView.maxLines = Integer.MAX_VALUE - textView.selectAll() - } - - fun attach() { - textView.setOnTouchListener(this) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt index 8d6275875..110b1ab33 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/adapter/ChapterListItemAD.kt @@ -25,7 +25,7 @@ fun chapterListItemAD( AdapterDelegateClickListenerAdapter(this, clickListener).attach(itemView) bind { - binding.textViewTitle.text = item.chapter.name + binding.textViewTitle.text = item.getTitle(context.resources) binding.textViewDescription.textAndVisible = item.description when { item.isCurrent -> { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt index f0085001b..5b3a09fb7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/ChapterListItem.kt @@ -1,7 +1,9 @@ package org.koitharu.kotatsu.details.ui.model +import android.content.res.Resources import android.text.format.DateUtils import org.jsoup.internal.StringUtil.StringJoiner +import org.koitharu.kotatsu.core.model.getLocalizedTitle import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.parsers.model.MangaChapter import kotlin.experimental.and @@ -11,6 +13,8 @@ data class ChapterListItem( val flags: Byte, ) : ListModel { + private var cachedTitle: String? = null + var description: String? = null private set get() { @@ -50,6 +54,21 @@ data class ChapterListItem( val isGrid: Boolean get() = hasFlag(FLAG_GRID) + operator fun contains(query: String): Boolean = with(chapter) { + title?.contains(query, ignoreCase = true) == true + || numberString()?.contains(query) == true + || volumeString()?.contains(query) == true + } + + fun getTitle(resources: Resources): String { + cachedTitle?.let { + return it + } + return chapter.getLocalizedTitle(resources).also { + cachedTitle = it + } + } + private fun buildDescription(): String { val joiner = StringJoiner(" • ") chapter.numberString()?.let { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt index a87a32b97..c11f445b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/HistoryInfo.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.details.ui.model import org.koitharu.kotatsu.core.model.MangaHistory import org.koitharu.kotatsu.details.data.MangaDetails +import org.koitharu.kotatsu.details.data.ReadingTime data class HistoryInfo( val totalChapters: Int, @@ -10,6 +11,7 @@ data class HistoryInfo( val isIncognitoMode: Boolean, val isChapterMissing: Boolean, val canDownload: Boolean, + val estimatedTime: ReadingTime?, ) { val isValid: Boolean get() = totalChapters >= 0 @@ -29,7 +31,8 @@ fun HistoryInfo( manga: MangaDetails?, branch: String?, history: MangaHistory?, - isIncognitoMode: Boolean + isIncognitoMode: Boolean, + estimatedTime: ReadingTime?, ): HistoryInfo { val chapters = if (manga?.chapters?.isEmpty() == true) { emptyList() @@ -48,5 +51,6 @@ fun HistoryInfo( isIncognitoMode = isIncognitoMode, isChapterMissing = history != null && manga?.isLoaded == true && manga.allChapters.none { it.id == history.chapterId }, canDownload = manga?.isLocal == false, + estimatedTime = estimatedTime, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/MangaBranch.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/MangaBranch.kt index 520f1de41..03eb72c95 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/MangaBranch.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/model/MangaBranch.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.details.ui.model import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel +import java.util.Locale data class MangaBranch( val name: String?, @@ -10,6 +11,8 @@ data class MangaBranch( val isCurrent: Boolean, ) : ListModel { + val locale: Locale? by lazy(::findAppropriateLocale) + override fun areItemsTheSame(other: ListModel): Boolean { return other is MangaBranch && other.name == name } @@ -25,4 +28,16 @@ data class MangaBranch( override fun toString(): String { return "$name: $count" } + + private fun findAppropriateLocale(): Locale? { + if (name.isNullOrEmpty()) { + return null + } + return Locale.getAvailableLocales().find { lc -> + name.contains(lc.getDisplayName(lc), ignoreCase = true) || + name.contains(lc.getDisplayName(Locale.ENGLISH), ignoreCase = true) || + name.contains(lc.getDisplayLanguage(lc), ignoreCase = true) || + name.contains(lc.getDisplayLanguage(Locale.ENGLISH), ignoreCase = true) + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesAdapter.kt index 34a98672f..3b4cfd729 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesAdapter.kt @@ -25,13 +25,21 @@ class ChaptersPagesAdapter( } override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { - tab.setText( + tab.setIcon( when (position) { - 0 -> R.string.chapters - 1 -> if (isPagesTabEnabled) R.string.pages else R.string.bookmarks - 2 -> R.string.bookmarks + 0 -> R.drawable.ic_list + 1 -> if (isPagesTabEnabled) R.drawable.ic_grid else R.drawable.ic_bookmark + 2 -> R.drawable.ic_bookmark else -> 0 }, ) + // tab.setText( + // when (position) { + // 0 -> R.string.chapters + // 1 -> if (isPagesTabEnabled) R.string.pages else R.string.bookmarks + // 2 -> R.string.bookmarks + // else -> 0 + // }, + // ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt index 9aba33af1..9a5171d4e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesSheet.kt @@ -5,11 +5,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.appcompat.view.ActionMode +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible -import androidx.fragment.app.FragmentManager +import androidx.transition.TransitionManager +import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_COLLAPSED import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior.Companion.STATE_DRAGGING @@ -19,21 +23,28 @@ import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.util.ActionModeListener import org.koitharu.kotatsu.core.ui.util.MenuInvalidator +import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.doOnPageChanged +import org.koitharu.kotatsu.core.util.ext.findCurrentPagerFragment +import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.menuView import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.recyclerView import org.koitharu.kotatsu.core.util.ext.setTabsEnabled -import org.koitharu.kotatsu.core.util.ext.showDistinct -import org.koitharu.kotatsu.core.util.ext.withArgs +import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop import org.koitharu.kotatsu.databinding.SheetChaptersPagesBinding +import org.koitharu.kotatsu.details.ui.DetailsViewModel +import org.koitharu.kotatsu.details.ui.ReadButtonDelegate import org.koitharu.kotatsu.download.ui.worker.DownloadStartedObserver import javax.inject.Inject @AndroidEntryPoint -class ChaptersPagesSheet : BaseAdaptiveSheet(), ActionModeListener, AdaptiveSheetCallback { +class ChaptersPagesSheet : BaseAdaptiveSheet(), + TabLayout.OnTabSelectedListener, + ActionModeListener, + AdaptiveSheetCallback { @Inject lateinit var settings: AppSettings @@ -49,16 +60,20 @@ class ChaptersPagesSheet : BaseAdaptiveSheet(), Actio disableFitToContents() val args = arguments ?: Bundle.EMPTY - var defaultTab = args.getInt(ARG_TAB, settings.defaultDetailsTab) + var defaultTab = args.getInt(AppRouter.KEY_TAB, settings.defaultDetailsTab) val adapter = ChaptersPagesAdapter(this, settings.isPagesTabEnabled) if (!adapter.isPagesTabEnabled) { defaultTab = (defaultTab - 1).coerceAtLeast(TAB_CHAPTERS) } + (viewModel as? DetailsViewModel)?.let { dvm -> + ReadButtonDelegate(binding.splitButtonRead, dvm, router).attach(viewLifecycleOwner) + } binding.pager.offscreenPageLimit = adapter.itemCount binding.pager.recyclerView?.isNestedScrollingEnabled = false binding.pager.adapter = adapter binding.pager.doOnPageChanged(::onPageChanged) TabLayoutMediator(binding.tabs, binding.pager, adapter).attach() + binding.tabs.addOnTabSelectedListener(this) binding.pager.setCurrentItem(defaultTab, false) binding.tabs.isVisible = adapter.itemCount > 1 @@ -78,16 +93,25 @@ class ChaptersPagesSheet : BaseAdaptiveSheet(), Actio viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.pager, this)) viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.pager, null)) viewModel.onDownloadStarted.observeEvent(viewLifecycleOwner, DownloadStartedObserver(binding.pager)) + } else { + PeekHeightController(arrayOf(binding.headerBar, binding.toolbar)).attach() } } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets + override fun onStateChanged(sheet: View, newState: Int) { if (newState == STATE_DRAGGING || newState == STATE_SETTLING) { return } val binding = viewBinding ?: return val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true - binding.toolbar.menuView?.isVisible = newState != STATE_COLLAPSED && !isActionModeStarted + if (sheet.context.isAnimationsEnabled) { + TransitionManager.beginDelayedTransition(binding.toolbar) + } + binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted + binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted + && viewModel is DetailsViewModel } override fun onActionModeStarted(mode: ActionMode) { @@ -101,6 +125,17 @@ class ChaptersPagesSheet : BaseAdaptiveSheet(), Actio viewBinding?.toolbar?.menuView?.isVisible = state != STATE_COLLAPSED } + override fun onTabSelected(tab: TabLayout.Tab?) = Unit + + override fun onTabUnselected(tab: TabLayout.Tab?) = Unit + + override fun onTabReselected(tab: TabLayout.Tab?) { + val f = childFragmentManager.findCurrentPagerFragment( + viewBinding?.pager ?: return, + ) as? RecyclerViewOwner ?: return + f.recyclerView?.smoothScrollToTop() + } + override fun expandAndLock() { super.expandAndLock() adjustLockState() @@ -138,22 +173,5 @@ class ChaptersPagesSheet : BaseAdaptiveSheet(), Actio const val TAB_CHAPTERS = 0 const val TAB_PAGES = 1 const val TAB_BOOKMARKS = 2 - private const val ARG_TAB = "tag" - private const val TAG = "ChaptersPagesSheet" - - fun show(fm: FragmentManager) { - ChaptersPagesSheet().showDistinct(fm, TAG) - } - - fun show(fm: FragmentManager, defaultTab: Int) { - ChaptersPagesSheet().withArgs(1) { - putInt(ARG_TAB, defaultTab) - }.showDistinct(fm, TAG) - } - - fun isShown(fm: FragmentManager): Boolean { - val sheet = fm.findFragmentByTag(TAG) as? ChaptersPagesSheet - return sheet?.dialog?.isShowing == true - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt index b7e20b006..3643a4c9e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt @@ -19,14 +19,17 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus import okio.FileNotFoundException import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository +import org.koitharu.kotatsu.core.model.toChipModel import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction +import org.koitharu.kotatsu.core.util.LocaleStringComparator import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.combine import org.koitharu.kotatsu.core.util.ext.requireValue +import org.koitharu.kotatsu.core.util.ext.sortedWithSafe import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.ui.DetailsActivity @@ -36,6 +39,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.download.ui.worker.DownloadTask import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga import org.koitharu.kotatsu.parsers.model.Manga @@ -57,7 +61,6 @@ abstract class ChaptersPagesViewModel( val readingState = MutableStateFlow(null) val onActionDone = MutableEventFlow() - val onSelectChapter = MutableEventFlow() val onDownloadStarted = MutableEventFlow() val onMangaRemoved = MutableEventFlow() @@ -68,6 +71,10 @@ abstract class ChaptersPagesViewModel( .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + val coverUrl = mangaDetails.map { x -> x?.coverUrl } + .withErrorHandling() + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + val isChaptersReversed = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_REVERSE_CHAPTERS, @@ -119,6 +126,18 @@ abstract class ChaptersPagesViewModel( (if (reversed) list.asReversed() else list).filterSearch(query) }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList()) + val quickFilter = combine( + mangaDetails, + selectedBranch, + ) { details, branch -> + val branches = details?.chapters?.keys?.sortedWithSafe(LocaleStringComparator()).orEmpty() + if (branches.size > 1) { + branches.map { ListFilterOption.Branch(it).toChipModel(it == branch) } + } else { + emptyList() + } + } + init { launchJob(Dispatchers.Default) { localStorageChanges @@ -197,9 +216,7 @@ abstract class ChaptersPagesViewModel( if (query.isEmpty() || this.isEmpty()) { return this } - return filter { - it.chapter.name.contains(query, ignoreCase = true) - } + return filter { it.contains(query) } } private suspend fun onDownloadComplete(downloadedManga: LocalManga?) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/PeekHeightController.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/PeekHeightController.kt new file mode 100644 index 000000000..fbf13f3f3 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/PeekHeightController.kt @@ -0,0 +1,62 @@ +package org.koitharu.kotatsu.details.ui.pager + +import android.view.View +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.OnApplyWindowInsetsListener +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.ancestors +import com.google.android.material.bottomsheet.BottomSheetBehavior + +class PeekHeightController( + private val views: Array, +) : View.OnLayoutChangeListener, OnApplyWindowInsetsListener { + + private var behavior: BottomSheetBehavior<*>? = null + + fun attach() { + behavior = findBehavior() ?: return + views.forEach { v -> + v.addOnLayoutChangeListener(this) + } + ViewCompat.setOnApplyWindowInsetsListener(views.first(), this) + } + + override fun onLayoutChange( + v: View?, + left: Int, + top: Int, + right: Int, + bottom: Int, + oldLeft: Int, + oldTop: Int, + oldRight: Int, + oldBottom: Int + ) { + if (top != oldTop || bottom != oldBottom) { + updatePeekHeight() + } + } + + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + updatePeekHeight() + return insets + } + + private fun updatePeekHeight() { + behavior?.peekHeight = views.sumOf { it.height } + getBottomInset() + } + + private fun getBottomInset(): Int = ViewCompat.getRootWindowInsets(views.first()) + ?.getInsets(WindowInsetsCompat.Type.navigationBars()) + ?.bottom ?: 0 + + private fun findBehavior(): BottomSheetBehavior<*>? { + return views.first().ancestors.firstNotNullOfOrNull { + ((it as? View)?.layoutParams as? CoordinatorLayout.LayoutParams)?.behavior as? BottomSheetBehavior<*> + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt index b554b645a..0237f1ac5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt @@ -8,9 +8,10 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R @@ -18,29 +19,35 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.ui.BookmarksSelectionDecoration import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.ReaderIntent +import org.koitharu.kotatsu.core.nav.dismissParentDialog +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper +import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver -import org.koitharu.kotatsu.core.util.ext.dismissParentDialog +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.FragmentMangaBookmarksBinding import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.list.ui.GridSpanResolver import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import javax.inject.Inject @AndroidEntryPoint class BookmarksFragment : BaseFragment(), - OnListItemClickListener, ListSelectionController.Callback { + OnListItemClickListener, + RecyclerViewOwner, + ListSelectionController.Callback { private val activityViewModel by ChaptersPagesViewModel.ActivityVMLazy(this) private val viewModel by viewModels() @@ -51,6 +58,9 @@ class BookmarksFragment : BaseFragment(), @Inject lateinit var settings: AppSettings + override val recyclerView: RecyclerView? + get() = viewBinding?.recyclerView + private var bookmarksAdapter: BookmarksAdapter? = null private var spanResolver: GridSpanResolver? = null private var selectionController: ListSelectionController? = null @@ -106,6 +116,17 @@ class BookmarksFragment : BaseFragment(), viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding?.recyclerView?.setPadding( + barsInsets.left, + barsInsets.top, + barsInsets.right, + barsInsets.bottom, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onDestroyView() { spanResolver = null bookmarksAdapter = null @@ -114,8 +135,6 @@ class BookmarksFragment : BaseFragment(), super.onDestroyView() } - override fun onWindowInsetsChanged(insets: Insets) = Unit - override fun onItemClick(item: Bookmark, view: View) { if (selectionController?.onItemClick(item.pageId) == true) { return @@ -124,21 +143,21 @@ class BookmarksFragment : BaseFragment(), if (listener != null && listener.onBookmarkSelected(item)) { dismissParentDialog() } else { - val intent = IntentBuilder(view.context) + val intent = ReaderIntent.Builder(view.context) .manga(activityViewModel.getMangaOrNull() ?: return) .bookmark(item) .incognito(true) .build() - startActivity(intent) + router.openReader(intent) } } override fun onItemLongClick(item: Bookmark, view: View): Boolean { - return selectionController?.onItemLongClick(view, item.pageId) ?: false + return selectionController?.onItemLongClick(view, item.pageId) == true } override fun onItemContextClick(item: Bookmark, view: View): Boolean { - return selectionController?.onItemContextClick(view, item.pageId) ?: false + return selectionController?.onItemContextClick(view, item.pageId) == true } override fun onSelectionChanged(controller: ListSelectionController, count: Int) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt index 0c82e7c83..56db72fd3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt @@ -4,57 +4,60 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.graphics.Insets -import androidx.core.view.ancestors +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager -import androidx.viewpager2.widget.ViewPager2 +import androidx.recyclerview.widget.RecyclerView +import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.ReaderIntent +import org.koitharu.kotatsu.core.nav.dismissParentDialog +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseFragment -import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper +import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner +import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback -import org.koitharu.kotatsu.core.util.ext.dismissParentDialog import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentChaptersBinding import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration import org.koitharu.kotatsu.details.ui.model.ChapterListItem import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.details.ui.withVolumeHeaders +import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderState -import javax.inject.Inject import kotlin.math.roundToInt @AndroidEntryPoint class ChaptersFragment : BaseFragment(), - OnListItemClickListener { + OnListItemClickListener, + RecyclerViewOwner, + ChipsView.OnChipClickListener { private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this) - @Inject - lateinit var commonAlertDialogs: CommonAlertDialogs - private var chaptersAdapter: ChaptersAdapter? = null private var selectionController: ListSelectionController? = null + override val recyclerView: RecyclerView? + get() = viewBinding?.recyclerViewChapters + override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -67,7 +70,7 @@ class ChaptersFragment : appCompatDelegate = checkNotNull(findAppCompatDelegate()), decoration = ChaptersSelectionDecoration(binding.root.context), registryOwner = this, - callback = ChaptersSelectionCallback(viewModel, commonAlertDialogs, binding.recyclerViewChapters), + callback = ChaptersSelectionCallback(viewModel, router, binding.recyclerViewChapters), ) viewModel.isChaptersInGridView.observe(viewLifecycleOwner) { chaptersInGridView -> binding.recyclerViewChapters.layoutManager = if (chaptersInGridView) { @@ -86,15 +89,16 @@ class ChaptersFragment : adapter = chaptersAdapter ChapterGridSpanHelper.attach(this) } + binding.chipsFilter.onChipClickListener = this viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) viewModel.chapters .map { it.withVolumeHeaders(requireContext()) } .flowOn(Dispatchers.Default) .observe(viewLifecycleOwner, this::onChaptersChanged) + viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged) viewModel.isChaptersEmpty.observe(viewLifecycleOwner) { binding.textViewHolder.isVisible = it } - viewModel.onSelectChapter.observeEvent(viewLifecycleOwner, ::onSelectChapter) } override fun onDestroyView() { @@ -111,8 +115,8 @@ class ChaptersFragment : if (listener != null && listener.onChapterSelected(item.chapter)) { dismissParentDialog() } else { - startActivity( - IntentBuilder(view.context) + router.openReader( + ReaderIntent.Builder(view.context) .manga(viewModel.getMangaOrNull() ?: return) .state(ReaderState(item.chapter.id, 0, 0)) .build(), @@ -121,14 +125,36 @@ class ChaptersFragment : } override fun onItemLongClick(item: ChapterListItem, view: View): Boolean { - return selectionController?.onItemLongClick(view, item.chapter.id) ?: false + return selectionController?.onItemLongClick(view, item.chapter.id) == true } override fun onItemContextClick(item: ChapterListItem, view: View): Boolean { - return selectionController?.onItemContextClick(view, item.chapter.id) ?: false + return selectionController?.onItemContextClick(view, item.chapter.id) == true } - override fun onWindowInsetsChanged(insets: Insets) = Unit + override fun onChipClick(chip: Chip, data: Any?) { + if (data !is ListFilterOption.Branch) return + viewModel.setSelectedBranch(data.titleText) + } + + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + viewBinding?.run { + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + recyclerViewChapters.updatePadding( + left = bars.left, + right = bars.right, + bottom = bars.bottom, + ) + chipsFilter.updatePadding( + left = bars.left, + right = bars.right, + ) + } + return WindowInsetsCompat.CONSUMED + } private fun onChaptersChanged(list: List) { val adapter = chaptersAdapter ?: return @@ -148,22 +174,10 @@ class ChaptersFragment : } } - private suspend fun onSelectChapter(chapterId: Long) { - if (!isResumed) { - view?.ancestors?.firstNotNullOfOrNull { it as? ViewPager2 }?.setCurrentItem(0, true) - } - val position = withContext(Dispatchers.Default) { - val predicate: (ListModel) -> Boolean = { x -> x is ChapterListItem && x.chapter.id == chapterId } - val items = chaptersAdapter?.observeItems()?.firstOrNull { it.any(predicate) } - items?.indexOfFirst(predicate) ?: -1 - } - if (position >= 0) { - selectionController?.startSelection(chapterId) - val lm = (viewBinding?.recyclerViewChapters?.layoutManager as? LinearLayoutManager) - if (lm != null) { - val offset = resources.getDimensionPixelOffset(R.dimen.chapter_list_item_height) - lm.scrollToPositionWithOffset(position, offset) - } + private fun onFilterChanged(list: List) { + viewBinding?.chipsFilter?.run { + setChips(list) + isGone = list.isEmpty() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt index 4c4925d75..fea035a47 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersSelectionCallback.kt @@ -8,7 +8,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource -import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.list.BaseListSelectionCallback import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.toCollection @@ -18,7 +18,7 @@ import org.koitharu.kotatsu.local.ui.LocalChaptersRemoveService class ChaptersSelectionCallback( private val viewModel: ChaptersPagesViewModel, - private val commonAlertDialogs: CommonAlertDialogs, + private val router: AppRouter, recyclerView: RecyclerView, ) : BaseListSelectionCallback(recyclerView) { @@ -63,10 +63,9 @@ class ChaptersSelectionCallback( val snapshot = controller.snapshot() mode?.finish() if (snapshot.isNotEmpty()) { - commonAlertDialogs.askForDownloadOverMeteredNetwork( - context = recyclerView.context, - onConfirmed = { viewModel.download(snapshot, it) }, - ) + router.askForDownloadOverMeteredNetwork { + viewModel.download(snapshot, it) + } } true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt index e63304849..6599dd281 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.details.ui.pager.pages -import android.webkit.MimeTypeMap import androidx.core.net.toUri import coil3.ImageLoader import coil3.decode.DataSource @@ -21,8 +20,10 @@ import okio.Path.Companion.toOkioPath import org.koitharu.kotatsu.core.network.MangaHttpClient import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.fetch import org.koitharu.kotatsu.core.util.ext.isNetworkUri +import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.mimeType @@ -47,7 +48,7 @@ class MangaPageFetcher( pagesCache.get(pageUrl)?.let { file -> return SourceFetchResult( source = ImageSource(file.toOkioPath(), options.fileSystem), - mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension), + mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(), dataSource = DataSource.DISK, ) } @@ -67,13 +68,13 @@ class MangaPageFetcher( if (!response.isSuccessful) { throw HttpException(response.toNetworkResponse()) } - val mimeType = response.mimeType + val mimeType = response.mimeType?.toMimeTypeOrNull() val file = response.requireBody().use { pagesCache.put(pageUrl, it.source(), mimeType) } SourceFetchResult( source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM), - mimeType = mimeType, + mimeType = mimeType?.toString(), dataSource = DataSource.NETWORK, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt index eadeec114..ead43b6dc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt @@ -9,7 +9,7 @@ import android.view.View import android.view.ViewGroup import androidx.appcompat.view.ActionMode import androidx.collection.ArraySet -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isInvisible import androidx.core.view.isVisible import androidx.fragment.app.viewModels @@ -22,14 +22,18 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flowOn import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.ReaderIntent +import org.koitharu.kotatsu.core.nav.dismissParentDialog +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper +import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback -import org.koitharu.kotatsu.core.util.ext.dismissParentDialog +import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe @@ -42,7 +46,6 @@ import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.reader.ui.PageSaveHelper -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback import org.koitharu.kotatsu.reader.ui.ReaderState import org.koitharu.kotatsu.reader.ui.pager.ReaderPage @@ -52,7 +55,9 @@ import kotlin.math.roundToInt @AndroidEntryPoint class PagesFragment : BaseFragment(), - OnListItemClickListener, ListSelectionController.Callback { + OnListItemClickListener, + RecyclerViewOwner, + ListSelectionController.Callback { @Inject lateinit var coil: ImageLoader @@ -74,6 +79,9 @@ class PagesFragment : private val spanSizeLookup = SpanSizeLookup() + override val recyclerView: RecyclerView? + get() = viewBinding?.recyclerView + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) pageSaveHelper = pageSaveHelperFactory.create(this) @@ -141,7 +149,17 @@ class PagesFragment : super.onDestroyView() } - override fun onWindowInsetsChanged(insets: Insets) = Unit + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeBask = WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeBask) + viewBinding?.recyclerView?.setPadding( + barsInsets.left, + barsInsets.top, + barsInsets.right, + barsInsets.bottom, + ) + return insets.consumeAll(typeBask) + } override fun onItemClick(item: PageThumbnail, view: View) { if (selectionController?.onItemClick(item.page.id) == true) { @@ -151,8 +169,8 @@ class PagesFragment : if (listener != null && listener.onPageSelected(item.page)) { dismissParentDialog() } else { - startActivity( - IntentBuilder(view.context) + router.openReader( + ReaderIntent.Builder(view.context) .manga(parentViewModel.getMangaOrNull() ?: return) .state(ReaderState(item.page.chapterId, item.page.index, 0)) .build(), @@ -161,11 +179,11 @@ class PagesFragment : } override fun onItemLongClick(item: PageThumbnail, view: View): Boolean { - return selectionController?.onItemLongClick(view, item.page.id) ?: false + return selectionController?.onItemLongClick(view, item.page.id) == true } override fun onItemContextClick(item: PageThumbnail, view: View): Boolean { - return selectionController?.onItemContextClick(view, item.page.id) ?: false + return selectionController?.onItemContextClick(view, item.page.id) == true } override fun onSelectionChanged(controller: ListSelectionController, count: Int) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt index 9840fc57f..082c5ecc2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesViewModel.kt @@ -137,7 +137,7 @@ class PagesViewModel @Inject constructor( this += PageThumbnail( isCurrent = readerState?.let { page.chapterId == it.chapterId && page.index == it.page - } ?: false, + } == true, page = page, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt index 679f36693..3a9e30e9b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt @@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.call @@ -37,7 +37,7 @@ class RelatedListViewModel @Inject constructor( downloadScheduler: DownloadWorker.Scheduler, ) : MangaListViewModel(settings, downloadScheduler) { - private val seed = savedStateHandle.require(MangaIntent.KEY_MANGA).manga + private val seed = savedStateHandle.require(AppRouter.KEY_MANGA).manga private val repository = mangaRepositoryFactory.create(seed.source) private val mangaList = MutableStateFlow?>(null) private val listError = MutableStateFlow(null) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt index fc8806f64..6e02fc0cd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt @@ -1,50 +1,5 @@ package org.koitharu.kotatsu.details.ui.related -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.core.ui.FragmentContainerActivity -@AndroidEntryPoint -class RelatedMangaActivity : BaseActivity(), AppBarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - replace(R.id.container, RelatedListFragment::class.java, intent.extras) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context, seed: Manga) = Intent(context, RelatedMangaActivity::class.java) - .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed)) - } -} +class RelatedMangaActivity : FragmentContainerActivity(RelatedListFragment::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt index 6b629ec8d..1e1990a15 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt @@ -1,10 +1,10 @@ package org.koitharu.kotatsu.details.ui.scrobbling -import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import coil3.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest @@ -15,12 +15,12 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo fun scrobblingInfoAD( lifecycleOwner: LifecycleOwner, coil: ImageLoader, - fragmentManager: FragmentManager, + router: AppRouter, ) = adapterDelegateViewBinding( { layoutInflater, parent -> ItemScrobblingInfoBinding.inflate(layoutInflater, parent, false) }, ) { binding.root.setOnClickListener { - ScrobblingInfoSheet.show(fragmentManager, bindingAdapterPosition) + router.showScrobblingInfoSheet(bindingAdapterPosition) } bind { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt index 69b25e63e..fa701d28d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.details.ui.scrobbling -import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.MenuItem @@ -10,14 +9,18 @@ import android.widget.AdapterView import android.widget.RatingBar import android.widget.Toast import androidx.appcompat.widget.PopupMenu -import androidx.core.net.toUri import androidx.core.text.method.LinkMovementMethodCompat -import androidx.fragment.app.FragmentManager +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import coil3.ImageLoader +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getDisplayMessage @@ -25,14 +28,10 @@ import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.sanitize -import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetScrobblingBinding import org.koitharu.kotatsu.details.ui.DetailsViewModel -import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus -import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet import javax.inject.Inject @AndroidEntryPoint @@ -53,7 +52,7 @@ class ScrobblingInfoSheet : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - scrobblerIndex = requireArguments().getInt(ARG_INDEX, scrobblerIndex) + scrobblerIndex = requireArguments().getInt(AppRouter.KEY_INDEX, scrobblerIndex) } override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding { @@ -85,6 +84,15 @@ class ScrobblingInfoSheet : menu = null } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + viewBinding?.root?.updatePadding( + bottom = insets.getInsets(typeMask).bottom, + ) + return insets.consume(v, typeMask, bottom = true) + } + + override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { viewModel.updateScrobbling( index = scrobblerIndex, @@ -108,11 +116,11 @@ class ScrobblingInfoSheet : override fun onClick(v: View) { when (v.id) { R.id.button_menu -> menu?.show() - R.id.imageView_cover -> { - val coverUrl = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return - val options = scaleUpActivityOptionsOf(v) - startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options) - } + R.id.imageView_cover -> router.openImage( + url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return, + source = null, + anchor = v, + ) } } @@ -139,10 +147,13 @@ class ScrobblingInfoSheet : when (item.itemId) { R.id.action_browser -> { val url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.externalUrl ?: return false - val intent = Intent(Intent.ACTION_VIEW, url.toUri()) - startActivity( - Intent.createChooser(intent, getString(R.string.open_in_browser)), - ) + if (!router.openExternalBrowser(url, getString(R.string.open_in_browser))) { + Snackbar.make( + viewBinding?.textViewDescription ?: return false, + R.string.operation_not_supported, + Snackbar.LENGTH_SHORT, + ).show() + } } R.id.action_unregister -> { @@ -153,20 +164,10 @@ class ScrobblingInfoSheet : R.id.action_edit -> { val manga = viewModel.manga.value ?: return false val scrobblerService = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.scrobbler - ScrobblingSelectorSheet.show(parentFragmentManager, manga, scrobblerService) + router.showScrobblingSelectorSheet(manga, scrobblerService) dismiss() } } return true } - - companion object { - - private const val TAG = "ScrobblingInfoBottomSheet" - private const val ARG_INDEX = "index" - - fun show(fm: FragmentManager, index: Int) = ScrobblingInfoSheet().withArgs(1) { - putInt(ARG_INDEX, index) - }.show(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt index fe205632a..f9ab7a6d0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt @@ -1,18 +1,18 @@ package org.koitharu.kotatsu.details.ui.scrobbling -import androidx.fragment.app.FragmentManager import androidx.lifecycle.LifecycleOwner import coil3.ImageLoader +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.list.ui.model.ListModel class ScrollingInfoAdapter( lifecycleOwner: LifecycleOwner, coil: ImageLoader, - fragmentManager: FragmentManager, + router: AppRouter, ) : BaseListAdapter() { init { - delegatesManager.addDelegate(scrobblingInfoAD(lifecycleOwner, coil, fragmentManager)) + delegatesManager.addDelegate(scrobblingInfoAD(lifecycleOwner, coil, router)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt index 76ce31f9a..3a4762f63 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.download.ui.dialog -import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -9,38 +8,32 @@ import android.view.ViewGroup import android.widget.Spinner import androidx.appcompat.widget.PopupMenu import androidx.core.view.isVisible -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentManager import androidx.fragment.app.FragmentResultListener import androidx.fragment.app.setFragmentResult import androidx.fragment.app.viewModels +import androidx.lifecycle.LifecycleOwner import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.DownloadFormat import org.koitharu.kotatsu.core.ui.AlertDialogFragment -import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs import org.koitharu.kotatsu.core.ui.widgets.TwoLinesItemView import org.koitharu.kotatsu.core.util.ext.findActivity import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.parentView -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.showOrHide -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.DialogDownloadBinding -import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.format -import org.koitharu.kotatsu.parsers.util.mapToArray import org.koitharu.kotatsu.settings.storage.DirectoryModel -import javax.inject.Inject @AndroidEntryPoint class DownloadDialogFragment : AlertDialogFragment(), View.OnClickListener { @@ -48,9 +41,6 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie private val viewModel by viewModels() private var optionViews: Array? = null - @Inject - lateinit var commonAlertDialogs: CommonAlertDialogs - override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?) = DialogDownloadBinding.inflate(inflater, container, false) @@ -75,6 +65,7 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie binding.buttonConfirm.setOnClickListener(this) binding.textViewMore.setOnClickListener(this) + binding.textViewTip.isVisible = viewModel.manga.size == 1 binding.textViewSummary.text = viewModel.manga.joinToStringWithLimit(binding.root.context, 120) { it.title } viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged) @@ -109,10 +100,7 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> dialog?.cancel() - R.id.button_confirm -> commonAlertDialogs.askForDownloadOverMeteredNetwork( - context = context ?: return, - onConfirmed = ::schedule, - ) + R.id.button_confirm -> router.askForDownloadOverMeteredNetwork(::schedule) R.id.textView_more -> { val binding = viewBinding ?: return @@ -182,7 +170,7 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie with(viewBinding ?: return) { // Whole manga optionWholeManga.subtitle = if (options.wholeManga.chaptersCount > 0) { - resources.getQuantityString( + resources.getQuantityStringSafe( R.plurals.chapters, options.wholeManga.chaptersCount, options.wholeManga.chaptersCount, @@ -198,7 +186,7 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie it.selectedBranch, ) optionWholeBranch.subtitle = if (it.chaptersCount > 0) { - resources.getQuantityString( + resources.getQuantityStringSafe( R.plurals.chapters, it.chaptersCount, it.chaptersCount, @@ -212,7 +200,7 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie options.firstChapters?.let { optionFirstChapters.title = resources.getString( R.string.download_option_first_n_chapters, - resources.getQuantityString( + resources.getQuantityStringSafe( R.plurals.chapters, it.chaptersCount, it.chaptersCount, @@ -228,7 +216,7 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie } else { resources.getString( R.string.download_option_next_unread_n_chapters, - resources.getQuantityString( + resources.getQuantityStringSafe( R.plurals.chapters, it.chaptersCount, it.chaptersCount, @@ -324,7 +312,9 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie } } - private class SnackbarResultListener(private val host: View) : FragmentResultListener { + private class SnackbarResultListener( + private val host: View, + ) : FragmentResultListener { override fun onFragmentResult(requestKey: String, result: Bundle) { val isStarted = result.getBoolean(ARG_STARTED, true) @@ -336,8 +326,9 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie (host.context.findActivity() as? BottomNavOwner)?.let { snackbar.anchorView = it.bottomNav } - snackbar.setAction(R.string.details) { - it.context.startActivity(Intent(it.context, DownloadsActivity::class.java)) + val router = AppRouter.from(host) + if (router != null) { + snackbar.setAction(R.string.details) { router.openDownloads() } } snackbar.show() } @@ -345,28 +336,16 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie companion object { - private const val TAG = "DownloadDialogFragment" private const val RESULT_KEY = "DOWNLOAD_STARTED" private const val ARG_STARTED = "started" private const val KEY_CHECKED_OPTION = "checked_opt" - const val ARG_MANGA = "manga" - fun show(fm: FragmentManager, manga: Collection) = DownloadDialogFragment().withArgs(1) { - putParcelableArray(ARG_MANGA, manga.mapToArray { ParcelableManga(it) }) - }.showDistinct(fm, TAG) + fun registerCallback( + fm: FragmentManager, + lifecycleOwner: LifecycleOwner, + snackbarHost: View + ) = fm.setFragmentResultListener(RESULT_KEY, lifecycleOwner, SnackbarResultListener(snackbarHost)) - fun registerCallback(activity: FragmentActivity, snackbarHost: View) = - activity.supportFragmentManager.setFragmentResultListener( - RESULT_KEY, - activity, - SnackbarResultListener(snackbarHost), - ) - - fun registerCallback(fragment: Fragment, snackbarHost: View) = - fragment.childFragmentManager.setFragmentResultListener( - RESULT_KEY, - fragment.viewLifecycleOwner, - SnackbarResultListener(snackbarHost), - ) + fun unregisterCallback(fm: FragmentManager) = fm.clearFragmentResultListener(RESULT_KEY) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt index 97a69844a..9095abb8d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.DownloadFormat @@ -45,7 +46,7 @@ class DownloadDialogViewModel @Inject constructor( private val settings: AppSettings, ) : BaseViewModel() { - val manga = savedStateHandle.require>(DownloadDialogFragment.ARG_MANGA).map { + val manga = savedStateHandle.require>(AppRouter.KEY_MANGA).map { it.manga } private val mangaDetails = suspendLazy { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt index 0b3175086..549289096 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadItemAD.kt @@ -21,6 +21,7 @@ import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.textAndVisible @@ -161,7 +162,7 @@ fun downloadItemAD( binding.progressBar.isEnabled = true binding.textViewPercent.isVisible = false if (item.chaptersDownloaded > 0) { - binding.textViewDetails.text = context.resources.getQuantityString( + binding.textViewDetails.text = context.resources.getQuantityStringSafe( R.plurals.chapters, item.chaptersDownloaded, item.chaptersDownloaded, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt index 0c823471e..87dd34145 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt @@ -8,10 +8,12 @@ import android.view.View import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper @@ -20,7 +22,6 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import javax.inject.Inject @@ -42,7 +43,7 @@ class DownloadsActivity : BaseActivity(), override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityDownloadsBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUp(true, false) val downloadsAdapter = DownloadsAdapter(this, coil, this) val decoration = TypedListSpacingDecoration(this, false) selectionController = ListSelectionController( @@ -67,24 +68,28 @@ class DownloadsActivity : BaseActivity(), viewModel.hasCancellableWorks.observe(this, menuInvalidator) } - override fun onWindowInsetsChanged(insets: Insets) { - val rv = viewBinding.recyclerView - rv.updatePadding( - left = insets.left + rv.paddingTop, - right = insets.right + rv.paddingTop, - bottom = insets.bottom, + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + viewBinding.recyclerView.updatePadding( + left = bars.left, + right = bars.right, + bottom = bars.bottom, ) - viewBinding.toolbar.updatePadding( - left = insets.left, - right = insets.right, + viewBinding.appbar.updatePadding( + left = bars.left, + right = bars.right, + top = bars.top, ) + return return WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) + .build() } override fun onItemClick(item: DownloadItemModel, view: View) { if (selectionController.onItemClick(item.id.mostSignificantBits)) { return } - startActivity(DetailsActivity.newIntent(view.context, item.manga ?: return)) + router.openDetails(item.manga ?: return) } override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt index 128412c2f..933948a93 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt @@ -1,16 +1,16 @@ package org.koitharu.kotatsu.download.ui.list -import android.content.Context import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider +import androidx.fragment.app.FragmentActivity import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog -import org.koitharu.kotatsu.settings.SettingsActivity class DownloadsMenuProvider( - private val context: Context, + private val activity: FragmentActivity, private val viewModel: DownloadsViewModel, ) : MenuProvider { @@ -24,10 +24,7 @@ class DownloadsMenuProvider( R.id.action_resume -> viewModel.resumeAll() R.id.action_cancel_all -> confirmCancelAll() R.id.action_remove_completed -> confirmRemoveCompleted() - R.id.action_settings -> { - context.startActivity(SettingsActivity.newDownloadsSettingsIntent(context)) - } - + R.id.action_settings -> activity.router.openDownloadsSetting() else -> return false } return true @@ -41,7 +38,7 @@ class DownloadsMenuProvider( } private fun confirmCancelAll() { - buildAlertDialog(context, isCentered = true) { + buildAlertDialog(activity, isCentered = true) { setTitle(R.string.cancel_all) setMessage(R.string.cancel_all_downloads_confirm) setIcon(R.drawable.ic_cancel_multiple) @@ -51,7 +48,7 @@ class DownloadsMenuProvider( } private fun confirmRemoveCompleted() { - buildAlertDialog(context, isCentered = true) { + buildAlertDialog(activity, isCentered = true) { setTitle(R.string.remove_completed) setMessage(R.string.remove_completed_downloads_confirm) setIcon(R.drawable.ic_clear_all) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt index 44795ee5b..6a74eb16b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt @@ -289,7 +289,7 @@ class DownloadsViewModel @Inject constructor( } return cacheMutex.withLock { mangaCache.getOrElse(mangaId) { - mangaDataRepository.findMangaById(mangaId)?.also { + mangaDataRepository.findMangaById(mangaId, withChapters = true)?.also { mangaCache[mangaId] = it } ?: return null } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt index 016bb14f2..67a5ac01a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt @@ -19,23 +19,22 @@ import coil3.size.Scale import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ErrorReporterReceiver +import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.model.LocalMangaSource +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.isReportable import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.download.domain.DownloadState import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.runCatchingCancellable -import org.koitharu.kotatsu.search.ui.MangaListActivity import java.util.UUID import com.google.android.material.R as materialR @@ -44,7 +43,7 @@ private const val CHANNEL_ID_SILENT = "download_bg" private const val GROUP_ID = "downloads" class DownloadNotificationFactory @AssistedInject constructor( - @ApplicationContext private val context: Context, + @LocalizedAppContext private val context: Context, private val workManager: WorkManager, private val coil: ImageLoader, @Assisted private val uuid: UUID, @@ -267,9 +266,9 @@ class DownloadNotificationFactory @AssistedInject constructor( context, manga.hashCode(), if (manga != null) { - DetailsActivity.newIntent(context, manga) + AppRouter.detailsIntent(context, manga) } else { - MangaListActivity.newIntent(context, LocalMangaSource, null) + AppRouter.listIntent(context, LocalMangaSource, null, null) }, PendingIntent.FLAG_CANCEL_CURRENT, false, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt index 7ec69616e..a418c0a7c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt @@ -1,12 +1,11 @@ package org.koitharu.kotatsu.download.ui.worker -import android.content.Intent import android.view.View import com.google.android.material.snackbar.Snackbar import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.util.ext.findActivity -import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner class DownloadStartedObserver( @@ -18,8 +17,9 @@ class DownloadStartedObserver( (snackbarHost.context.findActivity() as? BottomNavOwner)?.let { snackbar.anchorView = it.bottomNav } - snackbar.setAction(R.string.details) { - it.context.startActivity(Intent(it.context, DownloadsActivity::class.java)) + val router = AppRouter.from(snackbarHost) + if (router != null) { + snackbar.setAction(R.string.details) { router.openDownloads() } } snackbar.show() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt index 45d6c2438..5ec73a7f8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt @@ -5,7 +5,6 @@ import android.app.NotificationManager import android.content.Context import android.content.pm.ServiceInfo import android.os.Build -import android.webkit.MimeTypeMap import androidx.core.content.ContextCompat import androidx.hilt.work.HiltWorker import androidx.work.BackoffPolicy @@ -25,6 +24,7 @@ import dagger.assisted.Assisted import dagger.assisted.AssistedInject import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.NonCancellable import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow @@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Semaphore import kotlinx.coroutines.sync.withPermit import kotlinx.coroutines.withContext @@ -42,6 +43,7 @@ import okio.buffer import okio.sink import okio.use import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.network.MangaHttpClient @@ -49,7 +51,9 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.Throttler +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag @@ -61,6 +65,7 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getWorkInputData import org.koitharu.kotatsu.core.util.ext.getWorkSpec import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.withTicker import org.koitharu.kotatsu.core.util.ext.writeAllCancellable import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator @@ -120,7 +125,7 @@ class DownloadWorker @AssistedInject constructor( override suspend fun doWork(): Result { setForeground(getForegroundInfo()) - val manga = mangaDataRepository.findMangaById(task.mangaId) ?: return Result.failure() + val manga = mangaDataRepository.findMangaById(task.mangaId, withChapters = true) ?: return Result.failure() publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it }) val downloadedIds = getDoneChapters(manga) return try { @@ -132,7 +137,7 @@ class DownloadWorker @AssistedInject constructor( downloadMangaImpl(manga, task, downloadedIds) } Result.success(currentState.toWorkData()) - } catch (e: CancellationException) { + } catch (_: CancellationException) { withContext(NonCancellable) { val notification = notificationFactory.create(currentState.copy(isStopped = true)) notificationManager.notify(id.hashCode(), notification) @@ -201,7 +206,7 @@ class DownloadWorker @AssistedInject constructor( val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl } if (!coverUrl.isNullOrEmpty()) { downloadFile(coverUrl, destination, repo.source).let { file -> - output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl)) + output.addCover(file, getMediaType(coverUrl, file)) file.deleteAwait() } } @@ -230,7 +235,7 @@ class DownloadWorker @AssistedInject constructor( chapter = chapter, file = file, pageNumber = pageIndex, - ext = MimeTypeMap.getFileExtensionFromUrl(url), + type = getMediaType(url, file), ) if (file.extension == "tmp") { file.deleteAwait() @@ -354,6 +359,13 @@ class DownloadWorker @AssistedInject constructor( } } + private suspend fun getMediaType(url: String, file: File): MimeType? = runInterruptible(Dispatchers.IO) { + BitmapDecoderCompat.probeMimeType(file)?.let { + return@runInterruptible it + } + MimeTypes.getMimeTypeFromUrl(url) + } + private suspend fun downloadFile( url: String, destination: File, @@ -364,18 +376,29 @@ class DownloadWorker @AssistedInject constructor( return imageProxyInterceptor.interceptPageRequest(request, okHttp) .ensureSuccess() .use { response -> - val file = File(destination, UUID.randomUUID().toString() + ".tmp") + var file: File? = null try { response.requireBody().use { body -> + file = File( + destination, + buildString { + append(UUID.randomUUID().toString()) + MimeTypes.getExtension(body.contentType()?.toMimeType())?.let { ext -> + append('.') + append(ext) + } + append(".tmp") + }, + ) file.sink(append = false).buffer().use { it.writeAllCancellable(body.source()) } } } catch (e: CancellationException) { - file.delete() + file?.delete() throw e } - file + checkNotNull(file) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt index 04249df34..770890500 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt @@ -14,9 +14,11 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart import org.koitharu.kotatsu.BuildConfig +import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.db.dao.MangaSourcesDao import org.koitharu.kotatsu.core.db.entity.MangaSourceEntity @@ -41,7 +43,7 @@ import javax.inject.Singleton @Singleton class MangaSourcesRepository @Inject constructor( - @ApplicationContext private val context: Context, + @LocalizedAppContext private val context: Context, private val db: MangaDatabase, private val settings: AppSettings, ) { @@ -61,13 +63,14 @@ class MangaSourcesRepository @Inject constructor( suspend fun getEnabledSources(): List { assimilateNewSources() val order = settings.sourcesSortOrder - return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order).let { enabled -> - val external = getExternalSources() - val list = ArrayList(enabled.size + external.size) - external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) } - list.addAll(enabled) - list - } + return dao.findAll(!settings.isAllSourcesEnabled, order).toSources(settings.isNsfwContentDisabled, order) + .let { enabled -> + val external = getExternalSources() + val list = ArrayList(enabled.size + external.size) + external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) } + list.addAll(enabled) + list + } } suspend fun getPinnedSources(): Set { @@ -85,6 +88,9 @@ class MangaSourcesRepository @Inject constructor( suspend fun getDisabledSources(): Set { assimilateNewSources() + if (settings.isAllSourcesEnabled) { + return emptySet() + } val result = EnumSet.copyOf(allMangaSources) val enabled = dao.findAllEnabledNames() for (name in enabled) { @@ -105,7 +111,7 @@ class MangaSourcesRepository @Inject constructor( ): List { assimilateNewSources() val entities = dao.findAll().toMutableList() - if (isDisabledOnly) { + if (isDisabledOnly && !settings.isAllSourcesEnabled) { entities.removeAll { it.isEnabled } } if (isNewOnly) { @@ -141,7 +147,9 @@ class MangaSourcesRepository @Inject constructor( fun observeEnabledSourcesCount(): Flow { return combine( observeIsNsfwDisabled(), - dao.observeEnabled(SourcesSortOrder.MANUAL), + observeAllEnabled().flatMapLatest { isAllSourcesEnabled -> + dao.observeAll(!isAllSourcesEnabled, SourcesSortOrder.MANUAL) + }, ) { skipNsfw, sources -> sources.count { it.source.toMangaSourceOrNull()?.let { s -> !skipNsfw || !s.isNsfw() } == true @@ -152,7 +160,9 @@ class MangaSourcesRepository @Inject constructor( fun observeAvailableSourcesCount(): Flow { return combine( observeIsNsfwDisabled(), - dao.observeEnabled(SourcesSortOrder.MANUAL), + observeAllEnabled().flatMapLatest { isAllSourcesEnabled -> + dao.observeAll(!isAllSourcesEnabled, SourcesSortOrder.MANUAL) + }, ) { skipNsfw, enabledSources -> val enabled = enabledSources.mapToSet { it.source } allMangaSources.count { x -> @@ -163,9 +173,10 @@ class MangaSourcesRepository @Inject constructor( fun observeEnabledSources(): Flow> = combine( observeIsNsfwDisabled(), + observeAllEnabled(), observeSortOrder(), - ) { skipNsfw, order -> - dao.observeEnabled(order).map { + ) { skipNsfw, allEnabled, order -> + dao.observeAll(!allEnabled, order).map { it.toSources(skipNsfw, order) } }.flattenLatest() @@ -249,10 +260,11 @@ class MangaSourcesRepository @Inject constructor( return false } var maxSortKey = dao.getMaxSortKey() + val isAllEnabled = settings.isAllSourcesEnabled val entities = new.map { x -> MangaSourceEntity( source = x.name, - isEnabled = false, + isEnabled = isAllEnabled, sortKey = ++maxSortKey, addedIn = BuildConfig.VERSION_CODE, lastUsedAt = 0, @@ -355,6 +367,7 @@ class MangaSourcesRepository @Inject constructor( skipNsfwSources: Boolean, sortOrder: SourcesSortOrder?, ): MutableList { + val isAllEnabled = settings.isAllSourcesEnabled val result = ArrayList(size) for (entity in this) { val source = entity.source.toMangaSourceOrNull() ?: continue @@ -365,7 +378,7 @@ class MangaSourcesRepository @Inject constructor( result.add( MangaSourceInfo( mangaSource = source, - isEnabled = entity.isEnabled, + isEnabled = entity.isEnabled || isAllEnabled, isPinned = entity.isPinned, ), ) @@ -385,5 +398,9 @@ class MangaSourcesRepository @Inject constructor( sourcesSortOrder } + private fun observeAllEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ENABLED_ALL) { + isAllSourcesEnabled + } + private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt index de68d0922..660eae254 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt @@ -12,8 +12,7 @@ import android.view.MenuItem import android.view.View import android.view.ViewGroup import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.GridLayoutManager import androidx.recyclerview.widget.LinearLayoutManager @@ -21,9 +20,9 @@ import androidx.recyclerview.widget.RecyclerView import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.LocalMangaSource +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog @@ -33,12 +32,12 @@ import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.util.SpanSizeResolver import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.FragmentExploreBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.list.DownloadsActivity import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem @@ -46,10 +45,6 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaParserSource -import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.settings.SettingsActivity -import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity -import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity import javax.inject.Inject @AndroidEntryPoint @@ -66,8 +61,8 @@ class ExploreFragment : private var exploreAdapter: ExploreAdapter? = null private var sourceSelectionController: ListSelectionController? = null - override val recyclerView: RecyclerView - get() = requireViewBinding().recyclerView + override val recyclerView: RecyclerView? + get() = viewBinding?.recyclerView override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentExploreBinding { return FragmentExploreBinding.inflate(inflater, container, false) @@ -76,7 +71,7 @@ class ExploreFragment : override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this) { manga, view -> - startActivity(DetailsActivity.newIntent(view.context, manga)) + router.openDetails(manga) } sourceSelectionController = ListSelectionController( appCompatDelegate = checkNotNull(findAppCompatDelegate()), @@ -91,7 +86,7 @@ class ExploreFragment : addItemDecoration(TypedListSpacingDecoration(context, false)) checkNotNull(sourceSelectionController).attachToRecyclerView(this) } - addMenuProvider(ExploreMenuProvider(binding.root.context)) + addMenuProvider(ExploreMenuProvider(router)) viewModel.content.observe(viewLifecycleOwner, checkNotNull(exploreAdapter)) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga) @@ -102,64 +97,62 @@ class ExploreFragment : } } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + val basePadding = v.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) + viewBinding?.recyclerView?.setPadding( + /* left = */ barsInsets.left + basePadding, + /* top = */ basePadding, + /* right = */ barsInsets.right + basePadding, + /* bottom = */ barsInsets.bottom + basePadding, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onDestroyView() { super.onDestroyView() sourceSelectionController = null exploreAdapter = null } - override fun onWindowInsetsChanged(insets: Insets) { - val rv = requireViewBinding().recyclerView - rv.updatePadding( - bottom = insets.bottom + rv.paddingTop, - ) - } - override fun onListHeaderClick(item: ListHeader, view: View) { if (item.payload == R.id.nav_suggestions) { - startActivity(SuggestionsActivity.newIntent(view.context)) + router.openSuggestions() + } else if (viewModel.isAllSourcesEnabled.value) { + router.openManageSources() } else { - startActivity(Intent(view.context, SourcesCatalogActivity::class.java)) + router.openSourcesCatalog() } } override fun onClick(v: View) { - val intent = when (v.id) { - R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource, null) - R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context) - R.id.button_more -> SuggestionsActivity.newIntent(v.context) - R.id.button_downloads -> Intent(v.context, DownloadsActivity::class.java) - R.id.button_random -> { - viewModel.openRandom() - return - } - - else -> return + when (v.id) { + R.id.button_local -> router.openList(LocalMangaSource, null, null) + R.id.button_bookmarks -> router.openBookmarks() + R.id.button_more -> router.openSuggestions() + R.id.button_downloads -> router.openDownloads() + R.id.button_random -> viewModel.openRandom() } - startActivity(intent) } override fun onItemClick(item: MangaSourceItem, view: View) { if (sourceSelectionController?.onItemClick(item.id) == true) { return } - val intent = MangaListActivity.newIntent(view.context, item.source, null) - startActivity(intent) + router.openList(item.source, null, null) } override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean { - return sourceSelectionController?.onItemLongClick(view, item.id) ?: false + return sourceSelectionController?.onItemLongClick(view, item.id) == true } override fun onItemContextClick(item: MangaSourceItem, view: View): Boolean { - return sourceSelectionController?.onItemContextClick(view, item.id) ?: false + return sourceSelectionController?.onItemContextClick(view, item.id) == true } override fun onRetryClick(error: Throwable) = Unit - override fun onEmptyActionClick() { - startActivity(Intent(context ?: return, SourcesCatalogActivity::class.java)) - } + override fun onEmptyActionClick() = router.openSourcesCatalog() override fun onSelectionChanged(controller: ListSelectionController, count: Int) { viewBinding?.recyclerView?.invalidateItemDecorations() @@ -181,7 +174,8 @@ class ExploreFragment : menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection menu.findItem(R.id.action_pin).isVisible = selectedSources.all { !it.isPinned } menu.findItem(R.id.action_unpin).isVisible = selectedSources.all { it.isPinned } - menu.findItem(R.id.action_disable)?.isVisible = selectedSources.all { it.mangaSource is MangaParserSource } + menu.findItem(R.id.action_disable)?.isVisible = !viewModel.isAllSourcesEnabled.value && + selectedSources.all { it.mangaSource is MangaParserSource } menu.findItem(R.id.action_delete)?.isVisible = selectedSources.all { it.mangaSource is ExternalMangaSource } return super.onPrepareActionMode(controller, mode, menu) } @@ -194,7 +188,7 @@ class ExploreFragment : when (item.itemId) { R.id.action_settings -> { val source = selectedSources.singleOrNull() ?: return false - startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), source)) + router.openSourceSettings(source) mode?.finish() } @@ -232,8 +226,7 @@ class ExploreFragment : } private fun onOpenManga(manga: Manga) { - val intent = DetailsActivity.newIntent(context ?: return, manga) - startActivity(intent) + router.openDetails(manga) } private fun onGridModeChanged(isGrid: Boolean) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt index 86211b090..869ed767c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt @@ -1,15 +1,14 @@ package org.koitharu.kotatsu.explore.ui -import android.content.Context import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.settings.SettingsActivity +import org.koitharu.kotatsu.core.nav.AppRouter class ExploreMenuProvider( - private val context: Context, + private val router: AppRouter, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { @@ -19,7 +18,7 @@ class ExploreMenuProvider( override fun onMenuItemSelected(menuItem: MenuItem): Boolean { return when (menuItem.itemId) { R.id.action_manage -> { - context.startActivity(SettingsActivity.newSourcesSettingsIntent(context)) + router.openSourcesSettings() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt index 717d6c321..b297c1bd7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt @@ -7,7 +7,6 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.mapLatest @@ -23,6 +22,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.combine import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.explore.ui.model.ExploreButtons @@ -54,6 +54,12 @@ class ExploreViewModel @Inject constructor( valueProducer = { isSourcesGridMode }, ) + val isAllSourcesEnabled = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.IO, + key = AppSettings.KEY_SOURCES_ENABLED_ALL, + valueProducer = { isAllSourcesEnabled }, + ) + private val isSuggestionsEnabled = settings.observeAsFlow( key = AppSettings.KEY_SUGGESTIONS, valueProducer = { isSuggestionsEnabled }, @@ -137,9 +143,10 @@ class ExploreViewModel @Inject constructor( getSuggestionFlow(), isGrid, isRandomLoading, + isAllSourcesEnabled, sourcesRepository.observeHasNewSourcesForBadge(), - ) { content, suggestions, grid, randomLoading, newSources -> - buildList(content, suggestions, grid, randomLoading, newSources) + ) { content, suggestions, grid, randomLoading, allSourcesEnabled, newSources -> + buildList(content, suggestions, grid, randomLoading, allSourcesEnabled, newSources) }.withErrorHandling() private fun buildList( @@ -147,6 +154,7 @@ class ExploreViewModel @Inject constructor( recommendation: List, isGrid: Boolean, randomLoading: Boolean, + allSourcesEnabled: Boolean, hasNewSources: Boolean, ): List { val result = ArrayList(sources.size + 3) @@ -158,8 +166,8 @@ class ExploreViewModel @Inject constructor( if (sources.isNotEmpty()) { result += ListHeader( textRes = R.string.remote_sources, - buttonTextRes = R.string.catalog, - badge = if (hasNewSources) "" else null, + buttonTextRes = if (allSourcesEnabled) R.string.manage else R.string.catalog, + badge = if (!allSourcesEnabled && hasNewSources) "" else null, ) sources.mapTo(result) { MangaSourceItem(it, isGrid) } } else { @@ -196,8 +204,6 @@ class ExploreViewModel @Inject constructor( coverUrl = manga.coverUrl, manga = manga, counter = 0, - progress = null, - isFavorite = false, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt index a99afe241..c32e5537d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt @@ -16,6 +16,6 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong()) isVisibleInLibrary = isVisibleInLibrary, ) -fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags()) +fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags(), null) fun Collection.toMangaList() = map { it.toManga() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt index fd8376682..647ac9eaa 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/FavouritesDao.kt @@ -35,7 +35,15 @@ abstract class FavouritesDao : MangaQueryBuilder.ConditionCallback { @Transaction @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") - abstract suspend fun search(query: String, limit: Int): List + abstract suspend fun searchByTitle(query: String, limit: Int): List + + @Transaction + @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit") + abstract suspend fun searchByAuthor(query: String, limit: Int): List + + @Transaction + @Query("SELECT manga.* FROM favourites LEFT JOIN manga ON manga.manga_id = favourites.manga_id WHERE favourites.deleted_at = 0 AND EXISTS(SELECT 1 FROM tags LEFT JOIN manga_tags ON manga_tags.tag_id = tags.tag_id WHERE manga_tags.manga_id = manga.manga_id AND tags.title LIKE :query) LIMIT :limit") + abstract suspend fun searchByTag(query: String, limit: Int): List fun observeAll( order: ListSortOrder, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt index c65a2acce..f81b1aa50 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt @@ -25,6 +25,7 @@ import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.levenshteinDistance +import org.koitharu.kotatsu.search.domain.SearchKind import javax.inject.Inject @Reusable @@ -43,9 +44,17 @@ class FavouritesRepository @Inject constructor( return entities.toMangaList() } - suspend fun search(query: String, limit: Int): List { - val entities = db.getFavouritesDao().search("%$query%", limit) - return entities.toMangaList().sortedBy { it.title.levenshteinDistance(query) } + suspend fun search(query: String, kind: SearchKind, limit: Int): List { + val dao = db.getFavouritesDao() + val q = "%$query%" + val entities = when (kind) { + SearchKind.SIMPLE, + SearchKind.TITLE -> dao.searchByTitle(q, limit).sortedBy { it.manga.title.levenshteinDistance(query) } + + SearchKind.AUTHOR -> dao.searchByAuthor(q, limit) + SearchKind.TAG -> dao.searchByTag(q, limit) + } + return entities.toMangaList() } fun observeAll(order: ListSortOrder, filterOptions: Set, limit: Int): Flow> { @@ -199,6 +208,7 @@ class FavouritesRepository @Inject constructor( db.getFavouritesDao().deleteAll(id) db.getFavouriteCategoriesDao().delete(id) } + db.getChaptersDao().gc() } } @@ -238,6 +248,7 @@ class FavouritesRepository @Inject constructor( for (id in ids) { db.getFavouritesDao().delete(mangaId = id) } + db.getChaptersDao().gc() } return ReversibleHandle { recoverToFavourites(ids) } } @@ -247,6 +258,7 @@ class FavouritesRepository @Inject constructor( for (id in ids) { db.getFavouritesDao().delete(categoryId = categoryId, mangaId = id) } + db.getChaptersDao().gc() } return ReversibleHandle { recoverToCategory(categoryId, ids) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt index 59162a210..59c201d79 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt @@ -32,7 +32,7 @@ class LocalFavoritesObserver @Inject constructor( limit: Int ): Flow> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapToLocal() - override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags()) + override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags(), null) override fun toResult(e: FavouriteManga, manga: Manga) = manga } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt index dd275369d..694c082ff 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt @@ -1,56 +1,18 @@ package org.koitharu.kotatsu.favourites.ui -import android.content.Context -import android.content.Intent import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.FavouriteCategory -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.ui.FragmentContainerActivity import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment -import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID -@AndroidEntryPoint -class FavouritesActivity : BaseActivity() { +class FavouritesActivity : FragmentContainerActivity(FavouritesListFragment::class.java) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val categoryTitle = intent.getStringExtra(EXTRA_TITLE) + val categoryTitle = intent.getStringExtra(AppRouter.KEY_TITLE) if (categoryTitle != null) { title = categoryTitle } - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - val fragment = FavouritesListFragment.newInstance(intent.getLongExtra(EXTRA_CATEGORY_ID, NO_ID)) - replace(R.id.container, fragment) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - private const val EXTRA_CATEGORY_ID = "cat_id" - private const val EXTRA_TITLE = "title" - - fun newIntent(context: Context) = Intent(context, FavouritesActivity::class.java) - - fun newIntent(context: Context, category: FavouriteCategory) = Intent(context, FavouritesActivity::class.java) - .putExtra(EXTRA_CATEGORY_ID, category.id) - .putExtra(EXTRA_TITLE, category.title) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt index e8366ad69..641622af9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt @@ -46,16 +46,6 @@ class CategoriesSelectionCallback( override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean { return when (item.itemId) { - /*R.id.action_view -> { - val id = controller.peekCheckedIds().singleOrNull() ?: return false - val context = recyclerView.context - val category = viewModel.getCategory(id) ?: return false - val intent = FavouritesActivity.newIntent(context, category) - context.startActivity(intent) - mode.finish() - true - }*/ - R.id.action_show -> { viewModel.setIsVisible(controller.snapshot(), true) mode?.finish() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt index b9073cf47..88413354d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt @@ -2,10 +2,10 @@ package org.koitharu.kotatsu.favourites.ui.categories import android.os.Bundle import android.view.View -import android.view.ViewGroup +import android.view.ViewGroup.MarginLayoutParams import androidx.activity.viewModels import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.recyclerview.widget.ItemTouchHelper @@ -15,14 +15,16 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.ListSelectionController +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding -import org.koitharu.kotatsu.favourites.ui.FavouritesActivity import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoriesAdapter -import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel @@ -47,7 +49,7 @@ class FavouriteCategoriesActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityCategoriesBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUp(true, false) adapter = CategoriesAdapter(coil, this, this, this) selectionController = ListSelectionController( appCompatDelegate = delegate, @@ -69,32 +71,52 @@ class FavouriteCategoriesActivity : viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) } + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.recyclerView.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + viewBinding.appbar.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + top = barsInsets.top, + ) + viewBinding.fabAdd.updateLayoutParams { + marginEnd = topMargin + barsInsets.end(v) + bottomMargin = topMargin + barsInsets.bottom + } + return insets.consumeAllSystemBarsInsets() + } + override fun onClick(v: View) { when (v.id) { - R.id.fab_add -> startActivity(FavouritesCategoryEditActivity.newIntent(this)) + R.id.fab_add -> router.openFavoriteCategoryCreate() } } override fun onItemClick(item: FavouriteCategory?, view: View) { if (item == null) { if (selectionController.count == 0) { - startActivity(FavouritesActivity.newIntent(view.context)) + router.openFavorites() } return } if (selectionController.onItemClick(item.id)) { return } - val intent = FavouritesActivity.newIntent(view.context, item) - startActivity(intent) + router.openFavorites(item) } override fun onEditClick(item: FavouriteCategory, view: View) { if (selectionController.onItemClick(item.id)) { return } - val intent = FavouritesCategoryEditActivity.newIntent(view.context, item.id) - startActivity(intent) + router.openFavoriteCategoryEdit(item.id) } override fun onItemLongClick(item: FavouriteCategory?, view: View): Boolean { @@ -130,21 +152,6 @@ class FavouriteCategoriesActivity : override fun onEmptyActionClick() = Unit - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.fabAdd.updateLayoutParams { - rightMargin = topMargin + insets.right - leftMargin = topMargin + insets.left - bottomMargin = topMargin + insets.bottom - } - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.recyclerView.updatePadding( - bottom = insets.bottom + viewBinding.recyclerView.paddingTop, - ) - } - private suspend fun onCategoriesChanged(categories: List) { adapter.emit(categories) invalidateOptionsMenu() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt index ea4e032dd..96a86abb0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/adapter/CategoryAD.kt @@ -24,6 +24,7 @@ import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getAnimationDuration +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest @@ -79,7 +80,7 @@ fun categoryAD( binding.textViewSubtitle.text = if (item.mangaCount == 0) { getString(R.string.empty) } else { - context.resources.getQuantityString( + context.resources.getQuantityStringSafe( R.plurals.items, item.mangaCount, item.mangaCount, @@ -139,7 +140,7 @@ fun allCategoriesAD( binding.textViewSubtitle.text = if (item.mangaCount == 0) { getString(R.string.empty) } else { - context.resources.getQuantityString( + context.resources.getQuantityStringSafe( R.plurals.items, item.mangaCount, item.mangaCount, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt index c309c2df9..1b6b101f2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt @@ -1,33 +1,30 @@ package org.koitharu.kotatsu.favourites.ui.categories.edit import android.content.Context -import android.content.Intent import android.os.Bundle import android.text.Editable import android.view.View -import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.Filter import androidx.activity.viewModels -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getSerializableCompat import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityCategoryEditBinding import org.koitharu.kotatsu.list.domain.ListSortOrder -import com.google.android.material.R as materialR @AndroidEntryPoint class FavouritesCategoryEditActivity : @@ -43,10 +40,7 @@ class FavouritesCategoryEditActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityCategoryEditBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } + setDisplayHomeAsUp(true, true) initSortSpinner() viewBinding.buttonDone.setOnClickListener(this) viewBinding.editName.addTextChangedListener(this) @@ -61,6 +55,20 @@ class FavouritesCategoryEditActivity : } } + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.root.setPadding( + barsInsets.left, + barsInsets.top, + barsInsets.right, + barsInsets.bottom, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onSaveInstanceState(outState: Bundle) { super.onSaveInstanceState(outState) outState.putSerializable(KEY_SORT_ORDER, selectedSortOrder) @@ -68,8 +76,8 @@ class FavouritesCategoryEditActivity : override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) - savedInstanceState.getSerializableCompat(KEY_SORT_ORDER)?.let { - selectedSortOrder = it + savedInstanceState.getSerializableCompat(KEY_SORT_ORDER)?.let { + selectedSortOrder = it } } @@ -88,19 +96,6 @@ class FavouritesCategoryEditActivity : viewBinding.buttonDone.isEnabled = !s.isNullOrBlank() } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.scrollView.updatePadding( - bottom = insets.bottom, - ) - viewBinding.toolbar.updateLayoutParams { - topMargin = insets.top - } - } - override fun onItemClick(parent: AdapterView<*>?, view: View?, position: Int, id: Long) { selectedSortOrder = sortOrders.getOrNull(position) } @@ -114,8 +109,8 @@ class FavouritesCategoryEditActivity : selectedSortOrder = category?.order val sortText = getString((category?.order ?: ListSortOrder.NEWEST).titleResId) viewBinding.editSort.setText(sortText, false) - viewBinding.switchTracker.setChecked(category?.isTrackingEnabled ?: true, false) - viewBinding.switchShelf.setChecked(category?.isVisibleInLibrary ?: true, false) + viewBinding.switchTracker.setChecked(category?.isTrackingEnabled != false, false) + viewBinding.switchShelf.setChecked(category?.isVisibleInLibrary != false, false) } private fun onError(e: Throwable) { @@ -162,13 +157,7 @@ class FavouritesCategoryEditActivity : companion object { - const val EXTRA_ID = "id" const val NO_ID = -1L private const val KEY_SORT_ORDER = "sort" - - fun newIntent(context: Context, id: Long = NO_ID): Intent { - return Intent(context, FavouritesCategoryEditActivity::class.java) - .putExtra(EXTRA_ID, id) - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt index 6c9e8161f..c77c3320d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt @@ -10,12 +10,12 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.EXTRA_ID import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID import org.koitharu.kotatsu.list.domain.ListSortOrder import javax.inject.Inject @@ -27,7 +27,7 @@ class FavouritesCategoryEditViewModel @Inject constructor( private val settings: AppSettings, ) : BaseViewModel() { - private val categoryId = savedStateHandle[EXTRA_ID] ?: NO_ID + private val categoryId = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID val onSaved = MutableEventFlow() val category = MutableStateFlow(null) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialog.kt new file mode 100644 index 000000000..73b64a2d9 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialog.kt @@ -0,0 +1,129 @@ +package org.koitharu.kotatsu.favourites.ui.categories.select + +import android.content.DialogInterface +import android.content.res.ColorStateList +import android.graphics.Color +import android.graphics.drawable.ColorDrawable +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import android.widget.Toast +import androidx.core.graphics.ColorUtils +import androidx.core.view.isVisible +import androidx.core.widget.ImageViewCompat +import androidx.fragment.app.viewModels +import coil3.ImageLoader +import coil3.request.allowRgb565 +import coil3.request.crossfade +import coil3.request.error +import coil3.request.fallback +import coil3.request.placeholder +import com.google.android.material.checkbox.MaterialCheckBox +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router +import org.koitharu.kotatsu.core.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.disposeImageRequest +import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.getAnimationDuration +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit +import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra +import org.koitharu.kotatsu.core.util.ext.newImageRequest +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding +import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter +import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem +import javax.inject.Inject + +@AndroidEntryPoint +class FavoriteDialog : AlertDialogFragment(), + OnListItemClickListener, DialogInterface.OnClickListener { + + private val viewModel by viewModels() + + @Inject + lateinit var coil: ImageLoader + + override fun onCreateViewBinding( + inflater: LayoutInflater, + container: ViewGroup?, + ) = SheetFavoriteCategoriesBinding.inflate(inflater, container, false) + + override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { + return super.onBuildDialog(builder) + .setPositiveButton(R.string.done, null) + .setNeutralButton(R.string.manage, this) + } + + override fun onViewBindingCreated( + binding: SheetFavoriteCategoriesBinding, + savedInstanceState: Bundle?, + ) { + super.onViewBindingCreated(binding, savedInstanceState) + val adapter = MangaCategoriesAdapter(coil, viewLifecycleOwner, this) + binding.recyclerViewCategories.adapter = adapter + viewModel.content.observe(viewLifecycleOwner, adapter) + viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) + bindHeader() + } + + override fun onItemClick(item: MangaCategoryItem, view: View) { + viewModel.setChecked(item.category.id, item.checkedState != MaterialCheckBox.STATE_CHECKED) + } + + override fun onClick(dialog: DialogInterface?, which: Int) { + router.openFavoriteCategories() + } + + private fun onError(e: Throwable) { + Toast.makeText(context ?: return, e.getDisplayMessage(resources), Toast.LENGTH_SHORT).show() + } + + private fun bindHeader() { + val manga = viewModel.manga + val binding = viewBinding ?: return + val backgroundColor = binding.root.context.getThemeColor(android.R.attr.colorBackground) + ImageViewCompat.setImageTintList( + binding.imageViewCover3, + ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)), + ) + ImageViewCompat.setImageTintList( + binding.imageViewCover2, + ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)), + ) + binding.imageViewCover2.backgroundTintList = + ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)) + binding.imageViewCover3.backgroundTintList = + ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)) + val fallback = ColorDrawable(Color.TRANSPARENT) + val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3) + val crossFadeDuration = binding.root.context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt() + + binding.textViewTitle.text = manga.joinToStringWithLimit(binding.root.context, 92) { it.title } + + repeat(coverViews.size) { i -> + val m = manga.getOrNull(i) + val view = coverViews[i] + view.isVisible = m != null + if (m == null) { + view.disposeImageRequest() + } else { + view.newImageRequest(viewLifecycleOwner, m.coverUrl)?.run { + placeholder(R.drawable.ic_placeholder) + fallback(fallback) + mangaSourceExtra(m.source) + crossfade(crossFadeDuration * (i + 1)) + error(R.drawable.ic_error_placeholder) + allowRgb565(true) + enqueueWith(coil) + } + } + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialogViewModel.kt similarity index 71% rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialogViewModel.kt index 31442cc42..a922d9967 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialogViewModel.kt @@ -4,6 +4,7 @@ import androidx.collection.MutableLongObjectMap import androidx.collection.MutableLongSet import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope +import com.google.android.material.checkbox.MaterialCheckBox import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -11,40 +12,33 @@ import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.model.ids import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.nav.AppRouter 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.util.ext.require import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.favourites.domain.model.Cover -import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem +import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel -import org.koitharu.kotatsu.parsers.util.mapToSet +import org.koitharu.kotatsu.list.ui.model.LoadingState import javax.inject.Inject @HiltViewModel -class FavoriteSheetViewModel @Inject constructor( +class FavoriteDialogViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val favouritesRepository: FavouritesRepository, settings: AppSettings, ) : BaseViewModel() { - private val manga = savedStateHandle.require>(FavoriteSheet.KEY_MANGA_LIST).mapToSet { + val manga = savedStateHandle.require>(AppRouter.KEY_MANGA_LIST).map { it.manga } - private val header = CategoriesHeaderItem( - titles = manga.map { it.title }, - covers = manga.take(3).map { - Cover( - url = it.coverUrl, - source = it.source.name, - ) - }, - ) + private val refreshTrigger = MutableStateFlow(Any()) val content = combine( favouritesRepository.observeCategories(), @@ -53,7 +47,7 @@ class FavoriteSheetViewModel @Inject constructor( ) { categories, _, tracker -> mapList(categories, tracker) }.withErrorHandling() - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(header)) + .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) fun setChecked(categoryId: Long, isChecked: Boolean) { launchJob(Dispatchers.Default) { @@ -67,22 +61,32 @@ class FavoriteSheetViewModel @Inject constructor( } private suspend fun mapList(categories: List, tracker: Boolean): List { + if (categories.isEmpty()) { + return listOf( + EmptyState( + icon = 0, + textPrimary = R.string.empty_favourite_categories, + textSecondary = 0, + actionStringRes = 0, + ), + ) + } val cats = MutableLongObjectMap(categories.size) categories.forEach { cats[it.id] = MutableLongSet(manga.size) } for (m in manga) { val ids = favouritesRepository.getCategoriesIds(m.id) ids.forEach { id -> cats[id]?.add(m.id) } } - return buildList(categories.size + 1) { - add(header) - categories.mapTo(this) { cat -> - MangaCategoryItem( - category = cat, - isChecked = cats[cat.id]?.isNotEmpty() == true, - isTrackerEnabled = tracker, - isEnabled = cats[cat.id]?.let { it.size == 0 || it.size == manga.size } == true, - ) - } + return categories.map { cat -> + MangaCategoryItem( + category = cat, + checkedState = when (cats[cat.id]?.size ?: 0) { + 0 -> MaterialCheckBox.STATE_UNCHECKED + manga.size -> MaterialCheckBox.STATE_CHECKED + else -> MaterialCheckBox.STATE_INDETERMINATE + }, + isTrackerEnabled = tracker, + ) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt deleted file mode 100644 index 3f9188753..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt +++ /dev/null @@ -1,72 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.select - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import android.widget.Toast -import androidx.fragment.app.FragmentManager -import androidx.fragment.app.viewModels -import coil3.ImageLoader -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet -import org.koitharu.kotatsu.core.util.ext.getDisplayMessage -import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.showDistinct -import org.koitharu.kotatsu.core.util.ext.withArgs -import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding -import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter -import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem -import org.koitharu.kotatsu.parsers.model.Manga -import javax.inject.Inject - -@AndroidEntryPoint -class FavoriteSheet : BaseAdaptiveSheet(), OnListItemClickListener { - - private val viewModel by viewModels() - - @Inject - lateinit var coil: ImageLoader - - override fun onCreateViewBinding( - inflater: LayoutInflater, - container: ViewGroup?, - ) = SheetFavoriteCategoriesBinding.inflate(inflater, container, false) - - override fun onViewBindingCreated( - binding: SheetFavoriteCategoriesBinding, - savedInstanceState: Bundle?, - ) { - super.onViewBindingCreated(binding, savedInstanceState) - val adapter = MangaCategoriesAdapter(coil, viewLifecycleOwner, this) - binding.recyclerViewCategories.adapter = adapter - viewModel.content.observe(viewLifecycleOwner, adapter) - viewModel.onError.observeEvent(viewLifecycleOwner, ::onError) - } - - override fun onItemClick(item: MangaCategoryItem, view: View) { - viewModel.setChecked(item.category.id, !item.isChecked) - } - - private fun onError(e: Throwable) { - Toast.makeText(context ?: return, e.getDisplayMessage(resources), Toast.LENGTH_SHORT).show() - } - - companion object { - - private const val TAG = "FavoriteSheet" - const val KEY_MANGA_LIST = "manga_list" - - fun show(fm: FragmentManager, manga: Manga) = show(fm, setOf(manga)) - - fun show(fm: FragmentManager, manga: Collection) = FavoriteSheet().withArgs(1) { - putParcelableArrayList( - KEY_MANGA_LIST, - manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) }, - ) - }.showDistinct(fm, TAG) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt deleted file mode 100644 index 6f8fc332b..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt +++ /dev/null @@ -1,91 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.select.adapter - -import android.content.Intent -import android.content.res.ColorStateList -import android.graphics.Color -import android.graphics.drawable.ColorDrawable -import android.view.View -import androidx.core.graphics.ColorUtils -import androidx.core.view.isVisible -import androidx.core.widget.ImageViewCompat -import androidx.lifecycle.LifecycleOwner -import coil3.ImageLoader -import coil3.request.allowRgb565 -import coil3.request.crossfade -import coil3.request.error -import coil3.request.fallback -import coil3.request.placeholder -import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.disposeImageRequest -import org.koitharu.kotatsu.core.util.ext.enqueueWith -import org.koitharu.kotatsu.core.util.ext.getAnimationDuration -import org.koitharu.kotatsu.core.util.ext.getThemeColor -import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit -import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra -import org.koitharu.kotatsu.core.util.ext.newImageRequest -import org.koitharu.kotatsu.databinding.ItemCategoriesHeaderBinding -import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity -import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity -import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem -import org.koitharu.kotatsu.list.ui.model.ListModel - -fun categoriesHeaderAD( - coil: ImageLoader, - lifecycleOwner: LifecycleOwner, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemCategoriesHeaderBinding.inflate(inflater, parent, false) }, -) { - - val onClickListener = View.OnClickListener { v -> - val intent = when (v.id) { - R.id.chip_create -> FavouritesCategoryEditActivity.newIntent(v.context) - R.id.chip_manage -> Intent(v.context, FavouriteCategoriesActivity::class.java) - else -> return@OnClickListener - } - v.context.startActivity(intent) - } - - binding.chipCreate.setOnClickListener(onClickListener) - binding.chipManage.setOnClickListener(onClickListener) - - val backgroundColor = context.getThemeColor(android.R.attr.colorBackground) - ImageViewCompat.setImageTintList( - binding.imageViewCover3, - ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)), - ) - ImageViewCompat.setImageTintList( - binding.imageViewCover2, - ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)), - ) - binding.imageViewCover2.backgroundTintList = - ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)) - binding.imageViewCover3.backgroundTintList = - ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)) - val fallback = ColorDrawable(Color.TRANSPARENT) - val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3) - val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt() - - bind { - binding.textViewTitle.text = item.titles.joinToStringWithLimit(context, 120) { it } - - repeat(coverViews.size) { i -> - val cover = item.covers.getOrNull(i) - val view = coverViews[i] - view.isVisible = cover != null - if (cover == null) { - view.disposeImageRequest() - } else { - view.newImageRequest(lifecycleOwner, cover.url)?.run { - placeholder(R.drawable.ic_placeholder) - fallback(fallback) - mangaSourceExtra(cover.mangaSource) - crossfade(crossFadeDuration * (i + 1)) - error(R.drawable.ic_error_placeholder) - allowRgb565(true) - enqueueWith(coil) - } - } - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt index f820d6994..cb697f552 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt @@ -5,6 +5,9 @@ import coil3.ImageLoader import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem +import org.koitharu.kotatsu.list.ui.adapter.ListItemType +import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD +import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD import org.koitharu.kotatsu.list.ui.model.ListModel class MangaCategoriesAdapter( @@ -14,7 +17,8 @@ class MangaCategoriesAdapter( ) : BaseListAdapter() { init { - delegatesManager.addDelegate(mangaCategoryAD(clickListener)) - .addDelegate(categoriesHeaderAD(coil, lifecycleOwner)) + addDelegate(ListItemType.NAV_ITEM, mangaCategoryAD(clickListener)) + addDelegate(ListItemType.STATE_LOADING, loadingStateAD()) + addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt index 57c0f051a..3338f5068 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt @@ -1,10 +1,10 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.adapter -import androidx.core.view.isGone -import androidx.core.view.isVisible +import androidx.core.text.buildSpannedString import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.appendIcon import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener -import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.databinding.ItemCategoryCheckableBinding import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem import org.koitharu.kotatsu.list.ui.ListModelDiffCallback @@ -21,11 +21,20 @@ fun mangaCategoryAD( } bind { payloads -> - binding.root.isEnabled = item.isEnabled - binding.checkableImageView.isEnabled = item.isEnabled - binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads) - binding.textViewTitle.text = item.category.title - binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled - binding.imageViewHidden.isGone = item.category.isVisibleInLibrary + binding.checkBox.checkedState = item.checkedState + if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads) { + binding.checkBox.text = buildSpannedString { + append(item.category.title) + if (item.isTrackerEnabled && item.category.isTrackingEnabled) { + append(' ') + appendIcon(binding.checkBox, R.drawable.ic_notification) + } + if (!item.category.isVisibleInLibrary) { + append(' ') + appendIcon(binding.checkBox, R.drawable.ic_eye_off) + } + } + binding.checkBox.jumpDrawablesToCurrentState() + } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt deleted file mode 100644 index 7c294476d..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt +++ /dev/null @@ -1,23 +0,0 @@ -package org.koitharu.kotatsu.favourites.ui.categories.select.model - -import org.koitharu.kotatsu.favourites.domain.model.Cover -import org.koitharu.kotatsu.list.ui.model.ListModel - -data class CategoriesHeaderItem( - val titles: List, - val covers: List, -) : ListModel { - - override fun areItemsTheSame(other: ListModel): Boolean { - return other is CategoriesHeaderItem - } - - override fun equals(other: Any?): Boolean { - if (this === other) return true - return javaClass == other?.javaClass - } - - override fun hashCode(): Int { - return javaClass.hashCode() - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt index 0b76176a9..102200487 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt @@ -1,13 +1,13 @@ package org.koitharu.kotatsu.favourites.ui.categories.select.model +import com.google.android.material.checkbox.MaterialCheckBox.CheckedState import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel data class MangaCategoryItem( val category: FavouriteCategory, - val isChecked: Boolean, - val isEnabled: Boolean, + @CheckedState val checkedState: Int, val isTrackerEnabled: Boolean, ) : ListModel { @@ -16,7 +16,7 @@ data class MangaCategoryItem( } override fun getChangePayload(previousState: ListModel): Any? { - return if (previousState is MangaCategoryItem && previousState.isChecked != isChecked) { + return if (previousState is MangaCategoryItem && previousState.checkedState != checkedState) { ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED } else { super.getChangePayload(previousState) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabPopupMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabPopupMenuProvider.kt index dbd29e6c4..e05a852d1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabPopupMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabPopupMenuProvider.kt @@ -6,12 +6,13 @@ import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog -import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID class FavouriteTabPopupMenuProvider( private val context: Context, + private val router: AppRouter, private val viewModel: FavouritesContainerViewModel, private val categoryId: Long ) : MenuProvider { @@ -28,12 +29,9 @@ class FavouriteTabPopupMenuProvider( override fun onMenuItemSelected(menuItem: MenuItem): Boolean { when (menuItem.itemId) { R.id.action_hide -> viewModel.hide(categoryId) - R.id.action_edit -> context.startActivity( - FavouritesCategoryEditActivity.newIntent(context, categoryId), - ) - + R.id.action_edit -> router.openFavoriteCategoryEdit(categoryId) R.id.action_delete -> confirmDelete() - + R.id.action_manage -> router.openFavoriteCategories() else -> return false } return true diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt index 8c2e0e4cd..1687e5ab1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerAdapter.kt @@ -4,6 +4,7 @@ import androidx.fragment.app.Fragment import androidx.recyclerview.widget.AdapterListUpdateCallback import androidx.recyclerview.widget.AsyncDifferConfig import androidx.recyclerview.widget.AsyncListDiffer +import androidx.recyclerview.widget.RecyclerView import androidx.viewpager2.adapter.FragmentStateAdapter import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.asExecutor @@ -26,7 +27,7 @@ class FavouritesContainerAdapter(fragment: Fragment) : FragmentStateAdapter(frag override fun getItemCount(): Int = differ.currentList.size override fun getItemId(position: Int): Long { - return differ.currentList[position].id + return differ.currentList.getOrNull(position)?.id ?: RecyclerView.NO_ID } override fun containsItem(itemId: Long): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt index 217a4edb9..10905b98f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt @@ -1,26 +1,29 @@ package org.koitharu.kotatsu.favourites.ui.container -import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.ViewStub import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.core.view.updatePadding +import androidx.fragment.app.Fragment import androidx.fragment.app.viewModels +import androidx.recyclerview.widget.RecyclerView import coil3.ImageLoader import com.google.android.material.tabs.TabLayoutMediator import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.util.ActionModeListener +import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.findCurrentPagerFragment import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent @@ -29,18 +32,23 @@ import org.koitharu.kotatsu.core.util.ext.setTabsEnabled import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.databinding.FragmentFavouritesContainerBinding import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding -import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity import javax.inject.Inject @AndroidEntryPoint -class FavouritesContainerFragment : BaseFragment(), ActionModeListener, - ViewStub.OnInflateListener, View.OnClickListener { +class FavouritesContainerFragment : BaseFragment(), + ActionModeListener, + RecyclerViewOwner, + ViewStub.OnInflateListener, + View.OnClickListener { @Inject lateinit var coil: ImageLoader private val viewModel: FavouritesContainerViewModel by viewModels() + override val recyclerView: RecyclerView? + get() = (findCurrentFragment() as? RecyclerViewOwner)?.recyclerView + override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -55,13 +63,13 @@ class FavouritesContainerFragment : BaseFragment startActivity( - Intent(v.context, FavouriteCategoriesActivity::class.java), - ) + R.id.button_retry -> router.openFavoriteCategories() } } @@ -115,4 +116,10 @@ class FavouritesContainerFragment : BaseFragment { - context.startActivity(Intent(context, FavouriteCategoriesActivity::class.java)) + router.openFavoriteCategories() } else -> return false diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt index 8797e48ff..7614750c5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerViewModel.kt @@ -31,6 +31,7 @@ class FavouritesContainerViewModel @Inject constructor( val onActionDone = MutableEventFlow() private val categoriesStateFlow = favouritesRepository.observeCategoriesForLibrary() + .withErrorHandling() .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) val categories = combine( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesTabConfigurationStrategy.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesTabConfigurationStrategy.kt index c3fc540b1..e358d5777 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesTabConfigurationStrategy.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesTabConfigurationStrategy.kt @@ -3,17 +3,21 @@ package org.koitharu.kotatsu.favourites.ui.container import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator class FavouritesTabConfigurationStrategy( private val adapter: FavouritesContainerAdapter, private val viewModel: FavouritesContainerViewModel, + private val router: AppRouter, ) : TabConfigurationStrategy { override fun onConfigureTab(tab: TabLayout.Tab, position: Int) { val item = adapter.getItem(position) tab.text = item.title ?: tab.view.context.getString(R.string.all_favourites) tab.tag = item - PopupMenuMediator(FavouriteTabPopupMenuProvider(tab.view.context, viewModel, item.id)).attach(tab.view) + PopupMenuMediator( + FavouriteTabPopupMenuProvider(tab.view.context, router, viewModel, item.id) + ).attach(tab.view) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt index 474bd4159..463aa6908 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListFragment.kt @@ -11,6 +11,7 @@ import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.sortedByOrdinal import org.koitharu.kotatsu.core.util.ext.withArgs @@ -90,10 +91,9 @@ class FavouritesListFragment : MangaListFragment(), PopupMenu.OnMenuItemClickLis companion object { const val NO_ID = 0L - const val ARG_CATEGORY_ID = "category_id" fun newInstance(categoryId: Long) = FavouritesListFragment().withArgs(1) { - putLong(ARG_CATEGORY_ID, categoryId) + putLong(AppRouter.KEY_ID, categoryId) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt index fd559caa3..efb732b87 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt @@ -16,6 +16,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow @@ -25,7 +26,6 @@ import org.koitharu.kotatsu.core.util.ext.flattenLatest import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.favourites.domain.FavoritesListQuickFilter import org.koitharu.kotatsu.favourites.domain.FavouritesRepository -import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.ARG_CATEGORY_ID import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID import org.koitharu.kotatsu.history.domain.MarkAsReadUseCase import org.koitharu.kotatsu.list.domain.ListFilterOption @@ -54,7 +54,7 @@ class FavouritesListViewModel @Inject constructor( downloadScheduler: DownloadWorker.Scheduler, ) : MangaListViewModel(settings, downloadScheduler), QuickFilterListener { - val categoryId: Long = savedStateHandle[ARG_CATEGORY_ID] ?: NO_ID + val categoryId: Long = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID private val quickFilter = quickFilterFactory.create(categoryId) private val refreshTrigger = MutableStateFlow(Any()) private val limit = MutableStateFlow(PAGE_SIZE) @@ -144,7 +144,7 @@ class FavouritesListViewModel @Inject constructor( } val result = ArrayList(size + 1) quickFilter.filterItem(filters)?.let(result::add) - mangaListMapper.toListModelList(result, this, mode) + mangaListMapper.toListModelList(result, this, mode, MangaListMapper.NO_FAVORITE) return result } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt index 0d58c83a0..e7b873ab7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterCoordinator.kt @@ -267,6 +267,25 @@ class FilterCoordinator @Inject constructor( currentListFilter.value = value } + fun setAdjusted(value: MangaListFilter) { + var newFilter = value + if (!newFilter.author.isNullOrEmpty() && !capabilities.isAuthorSearchSupported) { + newFilter = newFilter.copy( + query = newFilter.author, + author = null, + ) + } + if (!capabilities.isSearchSupported && !newFilter.query.isNullOrEmpty()) { + newFilter = newFilter.copy( + query = null, + ) + } + if (!newFilter.query.isNullOrEmpty() && !newFilter.hasNonSearchOptions() && !capabilities.isSearchWithFiltersSupported) { + newFilter = MangaListFilter(query = newFilter.query) + } + set(newFilter) + } + fun setQuery(value: String?) { val newQuery = value?.trim()?.nullIfEmpty() currentListFilter.update { oldValue -> @@ -287,6 +306,15 @@ class FilterCoordinator @Inject constructor( } } + fun setAuthor(value: String?) { + currentListFilter.update { oldValue -> + oldValue.copy( + author = value, + query = oldValue.takeQueryIfSupported(), + ) + } + } + fun setOriginalLocale(value: Locale?) { currentListFilter.update { oldValue -> oldValue.copy( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt index b5b1f83c0..1fa9ed7a8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterFieldLayout.kt @@ -47,7 +47,7 @@ class FilterFieldLayout @JvmOverloads constructor( if (!isInitialized) { return } - assert(child.id != View.NO_ID) + assert(child.id != NO_ID) val lp = (child.layoutParams as? LayoutParams) ?: (generateDefaultLayoutParams() as LayoutParams) lp.alignWithParent = true lp.width = 0 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt index ff7d30917..61cbd65a6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt @@ -2,20 +2,21 @@ package org.koitharu.kotatsu.filter.ui import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel -import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.Demographic @@ -48,19 +49,24 @@ class FilterHeaderFragment : BaseFragment(), ChipsV .observe(viewLifecycleOwner, ::onDataChanged) } - override fun onWindowInsetsChanged(insets: Insets) = Unit + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets override fun onChipClick(chip: Chip, data: Any?) { when (data) { is MangaTag -> filter.toggleTag(data, !chip.isChecked) is String -> Unit - null -> TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false) + null -> router.showTagsCatalogSheet(excludeMode = false) } } override fun onChipCloseClick(chip: Chip, data: Any?) { when (data) { - is String -> filter.setQuery(null) + is String -> if (data == filter.snapshot().listFilter.author) { + filter.setAuthor(null) + } else { + filter.setQuery(null) + } + is ContentRating -> filter.toggleContentRating(data, false) is Demographic -> filter.toggleDemographic(data, false) is ContentType -> filter.toggleContentType(data, false) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt index e011beaa7..5f8d6d6dd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderProducer.kt @@ -135,6 +135,16 @@ class FilterHeaderProducer @Inject constructor( ), ) } + if (!snapshot.author.isNullOrEmpty()) { + result.addFirst( + ChipsView.ChipModel( + title = snapshot.author, + icon = R.drawable.ic_user, + isCloseable = true, + data = snapshot.author, + ), + ) + } val hasTags = result.any { it.data is MangaTag } if (hasTags) { result.addLast(moreTagsChip()) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt index f85bd4485..77c8e0c6a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt @@ -7,29 +7,28 @@ import android.view.View import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.updatePadding -import androidx.fragment.app.Fragment -import androidx.fragment.app.FragmentManager import com.google.android.material.chip.Chip import com.google.android.material.slider.RangeSlider import com.google.android.material.slider.Slider import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.parentView import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.setValuesRounded -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.databinding.SheetFilterBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.FilterProperty -import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.ContentType import org.koitharu.kotatsu.parsers.model.Demographic @@ -88,13 +87,21 @@ class FilterSheetFragment : BaseAdaptiveSheet(), binding.sliderYear.addOnChangeListener(this::onSliderValueChange) binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange) binding.layoutGenres.setOnMoreButtonClickListener { - TagsCatalogSheet.show(getChildFragmentManager(), isExcludeTag = false) + router.showTagsCatalogSheet(excludeMode = false) } binding.layoutGenresExclude.setOnMoreButtonClickListener { - TagsCatalogSheet.show(getChildFragmentManager(), isExcludeTag = true) + router.showTagsCatalogSheet(excludeMode = true) } } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + viewBinding?.scrollView?.updatePadding( + bottom = insets.getInsets(typeMask).bottom, + ) + return insets.consume(v, typeMask, bottom = true) + } + override fun onItemSelected(parent: AdapterView<*>, view: View?, position: Int, id: Long) { val filter = requireFilter() when (parent.id) { @@ -153,7 +160,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(), is ContentType -> filter.toggleContentType(data, !chip.isChecked) is ContentRating -> filter.toggleContentRating(data, !chip.isChecked) is Demographic -> filter.toggleDemographic(data, !chip.isChecked) - null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude) + null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude) } } @@ -351,13 +358,4 @@ class FilterSheetFragment : BaseAdaptiveSheet(), } private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator - - companion object { - - private const val TAG = "FilterSheet" - - fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG) - - fun isSupported(fragment: Fragment) = fragment.activity is FilterCoordinator.Owner - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt index 24e1e529e..398b748a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt @@ -2,38 +2,42 @@ package org.koitharu.kotatsu.filter.ui.tags import android.os.Bundle import android.text.Editable -import android.text.TextWatcher import android.view.KeyEvent import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.TextView -import androidx.fragment.app.FragmentManager +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import dagger.hilt.android.lifecycle.withCreationCallback +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher +import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showDistinct -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetTagsBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem @AndroidEntryPoint -class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickListener, TextWatcher, - AdaptiveSheetCallback, View.OnFocusChangeListener, TextView.OnEditorActionListener { +class TagsCatalogSheet : BaseAdaptiveSheet(), + OnListItemClickListener, + DefaultTextWatcher, + AdaptiveSheetCallback, + View.OnFocusChangeListener, + TextView.OnEditorActionListener { private val viewModel by viewModels( extrasProducer = { defaultViewModelCreationExtras.withCreationCallback { factory -> factory.create( filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator, - isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE), + isExcludeTag = requireArguments().getBoolean(AppRouter.KEY_EXCLUDE), ) } }, @@ -57,6 +61,18 @@ class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickL disableFitToContents() } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeBask = WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeBask) + viewBinding?.recyclerView?.setPadding( + barsInsets.left, + barsInsets.top, + barsInsets.right, + barsInsets.bottom, + ) + return insets.consumeAll(typeBask) + } + override fun onItemClick(item: TagCatalogItem, view: View) { viewModel.handleTagClick(item.tag, item.isChecked) } @@ -77,10 +93,6 @@ class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickL } } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - override fun afterTextChanged(s: Editable?) { val q = s?.toString().orEmpty() viewModel.searchQuery.value = q @@ -89,14 +101,4 @@ class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickL override fun onStateChanged(sheet: View, newState: Int) { viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED } - - companion object { - - private const val TAG = "TagsCatalogSheet" - private const val ARG_EXCLUDE = "exclude" - - fun show(fm: FragmentManager, isExcludeTag: Boolean) = TagsCatalogSheet().withArgs(1) { - putBoolean(ARG_EXCLUDE, isExcludeTag) - }.showDistinct(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt index dbe45dc9f..7f2561929 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryDao.kt @@ -26,7 +26,15 @@ abstract class HistoryDao : MangaQueryBuilder.ConditionCallback { @Transaction @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.title LIKE :query OR manga.alt_title LIKE :query) LIMIT :limit") - abstract suspend fun search(query: String, limit: Int): List + abstract suspend fun searchByTitle(query: String, limit: Int): List + + @Transaction + @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND (manga.author LIKE :query) LIMIT :limit") + abstract suspend fun searchByAuthor(query: String, limit: Int): List + + @Transaction + @Query("SELECT manga.* FROM history LEFT JOIN manga ON manga.manga_id = history.manga_id WHERE history.deleted_at = 0 AND EXISTS(SELECT 1 FROM tags LEFT JOIN manga_tags ON manga_tags.tag_id = tags.tag_id WHERE manga_tags.manga_id = manga.manga_id AND tags.title LIKE :query) LIMIT :limit") + abstract suspend fun searchByTag(query: String, limit: Int): List @Transaction @Query("SELECT * FROM history WHERE deleted_at = 0 ORDER BY updated_at DESC") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt index 7deeea3c9..e1e807c86 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt @@ -24,7 +24,7 @@ class HistoryLocalObserver @Inject constructor( limit: Int ) = db.getHistoryDao().observeAll(order, filterOptions, limit).mapToLocal() - override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags()) + override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags(), null) override fun toResult(e: HistoryWithManga, manga: Manga) = MangaWithHistory( manga = manga, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt index d93f2df01..80e5e21cb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt @@ -33,6 +33,7 @@ import org.koitharu.kotatsu.parsers.util.findById import org.koitharu.kotatsu.parsers.util.levenshteinDistance import org.koitharu.kotatsu.scrobbling.common.domain.Scrobbler import org.koitharu.kotatsu.scrobbling.common.domain.tryScrobble +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase import javax.inject.Inject import javax.inject.Provider @@ -49,12 +50,20 @@ class HistoryRepository @Inject constructor( suspend fun getList(offset: Int, limit: Int): List { val entities = db.getHistoryDao().findAll(offset, limit) - return entities.map { it.manga.toManga(it.tags.toMangaTags()) } + return entities.map { it.toManga() } } - suspend fun search(query: String, limit: Int): List { - val entities = db.getHistoryDao().search("%$query%", limit) - return entities.toMangaList().sortedBy { it.title.levenshteinDistance(query) } + suspend fun search(query: String, kind: SearchKind, limit: Int): List { + val dao = db.getHistoryDao() + val q = "%$query%" + val entities = when (kind) { + SearchKind.SIMPLE, + SearchKind.TITLE -> dao.searchByTitle(q, limit).sortedBy { it.manga.title.levenshteinDistance(query) } + + SearchKind.AUTHOR -> dao.searchByAuthor(q, limit) + SearchKind.TAG -> dao.searchByTag(q, limit) + } + return entities.toMangaList() } suspend fun getCount(): Int { @@ -63,25 +72,25 @@ class HistoryRepository @Inject constructor( suspend fun getLastOrNull(): Manga? { val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null - return entity.manga.toManga(entity.tags.toMangaTags()) + return entity.toManga() } fun observeLast(): Flow { return db.getHistoryDao().observeAll(1).map { val first = it.firstOrNull() - first?.manga?.toManga(first.tags.toMangaTags()) + first?.toManga() } } fun observeAll(): Flow> { return db.getHistoryDao().observeAll().mapItems { - it.manga.toManga(it.tags.toMangaTags()) + it.toManga() } } fun observeAll(limit: Int): Flow> { return db.getHistoryDao().observeAll(limit).mapItems { - it.manga.toManga(it.tags.toMangaTags()) + it.toManga() } } @@ -95,7 +104,7 @@ class HistoryRepository @Inject constructor( } return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems { MangaWithHistory( - it.manga.toManga(it.tags.toMangaTags()), + it.toManga(), it.history.toMangaHistory(), ) } @@ -145,10 +154,11 @@ class HistoryRepository @Inject constructor( suspend fun getProgress(mangaId: Long, mode: ProgressIndicatorMode): ReadingProgress? { val entity = db.getHistoryDao().find(mangaId) ?: return null + val fixedPercent = if (ReadingProgress.isCompleted(entity.percent)) 1f else entity.percent return ReadingProgress( - percent = entity.percent, + percent = fixedPercent, totalChapters = entity.chaptersCount, - mode = mode, + mode = mode ).takeIf { it.isValid() } } @@ -156,16 +166,19 @@ class HistoryRepository @Inject constructor( db.getHistoryDao().clear() } - suspend fun delete(manga: Manga) { + suspend fun delete(manga: Manga) = db.withTransaction { db.getHistoryDao().delete(manga.id) + mangaRepository.gcChaptersCache() } - suspend fun deleteAfter(minDate: Long) { + suspend fun deleteAfter(minDate: Long) = db.withTransaction { db.getHistoryDao().deleteAfter(minDate) + mangaRepository.gcChaptersCache() } - suspend fun deleteNotFavorite() { + suspend fun deleteNotFavorite() = db.withTransaction { db.getHistoryDao().deleteNotFavorite() + mangaRepository.gcChaptersCache() } suspend fun delete(ids: Collection): ReversibleHandle { @@ -173,6 +186,7 @@ class HistoryRepository @Inject constructor( for (id in ids) { db.getHistoryDao().delete(id) } + mangaRepository.gcChaptersCache() } return ReversibleHandle { recover(ids) @@ -185,7 +199,7 @@ class HistoryRepository @Inject constructor( */ suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) { if (alternative == null || db.getMangaDao().update(alternative.toEntity()) <= 0) { - db.getHistoryDao().delete(manga.id) + delete(manga) } } @@ -229,4 +243,6 @@ class HistoryRepository @Inject constructor( db.getHistoryDao().update(newEntity) return newEntity } + + private fun HistoryWithManga.toManga() = manga.toManga(tags.toMangaTags(), null) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt index 5c05fbf46..0cddefd99 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt @@ -1,49 +1,5 @@ package org.koitharu.kotatsu.history.ui -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner +import org.koitharu.kotatsu.core.ui.FragmentContainerActivity -@AndroidEntryPoint -class HistoryActivity : - BaseActivity(), - AppBarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - val fragment = HistoryListFragment() - replace(R.id.container, fragment) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, HistoryActivity::class.java) - } -} +class HistoryActivity : FragmentContainerActivity(HistoryListFragment::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt index c283b4fba..1af145c1b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt @@ -8,6 +8,7 @@ import androidx.appcompat.view.ActionMode import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper @@ -27,7 +28,7 @@ class HistoryListFragment : MangaListFragment() { override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) { super.onViewBindingCreated(binding, savedInstanceState) RecyclerScrollKeeper(binding.recyclerView).attach() - addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel)) + addMenuProvider(HistoryListMenuProvider(binding.root.context, router, viewModel)) viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt index 1c412c3ae..981dd2ca2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt @@ -1,15 +1,14 @@ package org.koitharu.kotatsu.history.ui import android.content.Context -import android.content.Intent import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog -import org.koitharu.kotatsu.stats.ui.StatsActivity import java.time.Instant import java.time.LocalDate import java.time.ZoneId @@ -17,6 +16,7 @@ import java.time.temporal.ChronoUnit class HistoryListMenuProvider( private val context: Context, + private val router: AppRouter, private val viewModel: HistoryListViewModel, ) : MenuProvider { @@ -37,7 +37,7 @@ class HistoryListMenuProvider( } R.id.action_stats -> { - context.startActivity(Intent(context, StatsActivity::class.java)) + router.openStatistic() true } @@ -46,7 +46,7 @@ class HistoryListMenuProvider( } private fun showClearHistoryDialog() { - val selectionListener = RememberSelectionDialogListener(2) + val selectionListener = RememberSelectionDialogListener(1) buildAlertDialog(context, isCentered = true) { setTitle(R.string.clear_history) setSingleChoiceItems( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt index 81de88924..221c58e37 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt @@ -31,6 +31,7 @@ import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.ListSortOrder import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.domain.QuickFilterListener +import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.ui.MangaListViewModel import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.InfoModel @@ -207,10 +208,10 @@ class HistoryListViewModel @Inject constructor( ListSortOrder.UNREAD, ListSortOrder.PROGRESS -> ListHeader( - when (percent) { - 1f -> R.string.status_completed - in 0f..0.01f -> R.string.status_planned - in 0f..1f -> R.string.status_reading + when { + ReadingProgress.isCompleted(percent) -> R.string.status_completed + percent in 0f..0.01f -> R.string.status_planned + percent in 0f..1f -> R.string.status_reading else -> R.string.unknown }, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt index 48a9f5e76..7cf75d2bd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt @@ -1,17 +1,18 @@ package org.koitharu.kotatsu.history.ui.util import android.content.Context +import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color -import android.graphics.ColorFilter import android.graphics.Paint -import android.graphics.PixelFormat import android.graphics.Rect -import android.graphics.drawable.Drawable +import android.os.Build +import androidx.annotation.RequiresApi import androidx.annotation.StyleRes import androidx.appcompat.content.res.AppCompatResources -import androidx.core.graphics.ColorUtils import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ui.image.PaintDrawable +import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified import org.koitharu.kotatsu.core.util.ext.scale import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE @@ -19,23 +20,29 @@ import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE class ReadingProgressDrawable( context: Context, @StyleRes styleResId: Int, -) : Drawable() { +) : PaintDrawable() { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) private val checkDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_check) - private val lineColor: Int - private val outlineColor: Int - private val backgroundColor: Int - private val textColor: Int + private val lineColor: ColorStateList + private val outlineColor: ColorStateList + private val backgroundColor: ColorStateList + private val textColor: ColorStateList private val textBounds = Rect() private val tempRect = Rect() - private val hasBackground: Boolean - private val hasOutline: Boolean - private val hasText: Boolean private val desiredHeight: Int private val desiredWidth: Int private val autoFitTextSize: Boolean + private var currentLineColor: Int = Color.TRANSPARENT + private var currentOutlineColor: Int = Color.TRANSPARENT + private var currentBackgroundColor: Int = Color.TRANSPARENT + private var currentTextColor: Int = Color.TRANSPARENT + private var hasBackground: Boolean = false + private var hasOutline: Boolean = false + private var hasText: Boolean = false + + var percent: Float = PROGRESS_NONE set(value) { field = value @@ -54,22 +61,22 @@ class ReadingProgressDrawable( desiredHeight = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_height, -1) desiredWidth = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_width, -1) autoFitTextSize = ta.getBoolean(R.styleable.ProgressDrawable_autoFitTextSize, false) - lineColor = ta.getColor(R.styleable.ProgressDrawable_android_strokeColor, Color.BLACK) - outlineColor = ta.getColor(R.styleable.ProgressDrawable_outlineColor, Color.TRANSPARENT) - backgroundColor = ColorUtils.setAlphaComponent( - ta.getColor(R.styleable.ProgressDrawable_android_fillColor, Color.TRANSPARENT), - (255 * ta.getFloat(R.styleable.ProgressDrawable_android_fillAlpha, 0f)).toInt(), + lineColor = ta.getColorStateList(R.styleable.ProgressDrawable_android_strokeColor) ?: ColorStateList.valueOf( + Color.BLACK, ) - textColor = ta.getColor(R.styleable.ProgressDrawable_android_textColor, lineColor) + outlineColor = + ta.getColorStateList(R.styleable.ProgressDrawable_outlineColor) ?: ColorStateList.valueOf(Color.TRANSPARENT) + backgroundColor = ta.getColorStateList(R.styleable.ProgressDrawable_android_fillColor)?.withAlpha( + (255 * ta.getFloat(R.styleable.ProgressDrawable_android_fillAlpha, 0f)).toInt(), + ) ?: ColorStateList.valueOf(Color.TRANSPARENT) + textColor = ta.getColorStateList(R.styleable.ProgressDrawable_android_textColor) ?: lineColor paint.strokeCap = Paint.Cap.ROUND paint.textAlign = Paint.Align.CENTER paint.textSize = ta.getDimension(R.styleable.ProgressDrawable_android_textSize, paint.textSize) paint.strokeWidth = ta.getDimension(R.styleable.ProgressDrawable_strokeWidth, 1f) ta.recycle() - hasBackground = Color.alpha(backgroundColor) != 0 - hasOutline = Color.alpha(outlineColor) != 0 - hasText = Color.alpha(textColor) != 0 && paint.textSize > 0 - checkDrawable?.setTint(textColor) + checkDrawable?.setTintList(textColor) + onStateChange(state) } override fun onBoundsChange(bounds: Rect) { @@ -91,16 +98,16 @@ class ReadingProgressDrawable( val radius = minOf(bounds.width(), bounds.height()) / 2f if (hasBackground) { paint.style = Paint.Style.FILL - paint.color = backgroundColor + paint.color = currentBackgroundColor canvas.drawCircle(cx, cy, radius, paint) } val innerRadius = radius - paint.strokeWidth / 2f paint.style = Paint.Style.STROKE if (hasOutline) { - paint.color = outlineColor + paint.color = currentOutlineColor canvas.drawCircle(cx, cy, innerRadius, paint) } - paint.color = lineColor + paint.color = currentLineColor canvas.drawArc( cx - innerRadius, cy - innerRadius, @@ -119,28 +126,48 @@ class ReadingProgressDrawable( checkDrawable.draw(canvas) } else { paint.style = Paint.Style.FILL - paint.color = textColor + paint.color = currentTextColor val ty = bounds.height() / 2f + textBounds.height() / 2f - textBounds.bottom canvas.drawText(text, cx, ty, paint) } } } - override fun setAlpha(alpha: Int) { - paint.alpha = alpha - } + override fun getIntrinsicHeight() = desiredHeight - override fun setColorFilter(colorFilter: ColorFilter?) { - paint.colorFilter = colorFilter - } + override fun getIntrinsicWidth() = desiredWidth - @Suppress("DeprecatedCallableAddReplaceWith") - @Deprecated("Deprecated in Java") - override fun getOpacity() = PixelFormat.TRANSLUCENT + override fun isStateful(): Boolean = lineColor.isStateful || + outlineColor.isStateful || + backgroundColor.isStateful || + textColor.isStateful || + checkDrawable?.isStateful == true - override fun getIntrinsicHeight() = desiredHeight + @RequiresApi(Build.VERSION_CODES.S) + override fun hasFocusStateSpecified(): Boolean = lineColor.hasFocusStateSpecified() || + outlineColor.hasFocusStateSpecified() || + backgroundColor.hasFocusStateSpecified() || + textColor.hasFocusStateSpecified() || + checkDrawable?.hasFocusStateSpecified() == true - override fun getIntrinsicWidth() = desiredWidth + override fun onStateChange(state: IntArray): Boolean { + val prevLineColor = currentLineColor + currentLineColor = lineColor.getColorForState(state, lineColor.defaultColor) + val prevOutlineColor = currentOutlineColor + currentOutlineColor = outlineColor.getColorForState(state, outlineColor.defaultColor) + val prevBackgroundColor = currentBackgroundColor + currentBackgroundColor = backgroundColor.getColorForState(state, backgroundColor.defaultColor) + val prevTextColor = currentTextColor + currentTextColor = textColor.getColorForState(state, textColor.defaultColor) + hasBackground = Color.alpha(currentBackgroundColor) != 0 + hasOutline = Color.alpha(currentOutlineColor) != 0 + hasText = Color.alpha(currentTextColor) != 0 && paint.textSize > 0 + return checkDrawable?.setState(state) == true || + prevLineColor != currentLineColor || + prevOutlineColor != currentOutlineColor || + prevBackgroundColor != currentBackgroundColor || + prevTextColor != currentTextColor + } private fun getTextSizeForWidth(width: Float, text: String): Float { val testTextSize = 48f diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt index c030f6222..12ebc8d07 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt @@ -1,15 +1,13 @@ package org.koitharu.kotatsu.image.ui -import android.content.Context -import android.content.Intent import android.graphics.drawable.Drawable import android.net.Uri import android.os.Bundle import android.view.View import android.view.ViewGroup import androidx.activity.viewModels -import androidx.core.graphics.Insets import androidx.core.graphics.drawable.toBitmap +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import androidx.swiperefreshlayout.widget.CircularProgressDrawable @@ -29,9 +27,12 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator import org.koitharu.kotatsu.core.util.ShareHelper +import org.koitharu.kotatsu.core.util.ext.consumeAll +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.getDisplayIcon import org.koitharu.kotatsu.core.util.ext.getDisplayMessage @@ -39,14 +40,16 @@ import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.databinding.ActivityImageBinding import org.koitharu.kotatsu.databinding.ItemErrorStateBinding -import org.koitharu.kotatsu.parsers.model.MangaSource import javax.inject.Inject import com.google.android.material.R as materialR @AndroidEntryPoint -class ImageActivity : BaseActivity(), ImageRequest.Listener, View.OnClickListener { +class ImageActivity : BaseActivity(), + ImageRequest.Listener, + View.OnClickListener { @Inject lateinit var coil: ImageLoader @@ -74,19 +77,6 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene loadImage(imageUrl) } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.buttonBack.updateLayoutParams { - topMargin = insets.top + bottomMargin - leftMargin = insets.left + bottomMargin - rightMargin = insets.right + bottomMargin - } - viewBinding.buttonMenu.updateLayoutParams { - topMargin = insets.top + bottomMargin - leftMargin = insets.left + bottomMargin - rightMargin = insets.right + bottomMargin - } - } - override fun onClick(v: View) { when (v.id) { R.id.button_back -> dispatchNavigateUp() @@ -117,13 +107,28 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene (errorBinding?.root ?: viewBinding.stubError).isVisible = false } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeMask) + val baseMargin = v.resources.getDimensionPixelOffset(R.dimen.screen_padding) + viewBinding.buttonMenu.updateLayoutParams { + marginEnd = barsInsets.end(v) + baseMargin + topMargin = barsInsets.top + baseMargin + } + viewBinding.buttonBack.updateLayoutParams { + marginStart = barsInsets.start(v) + baseMargin + topMargin = barsInsets.top + baseMargin + } + return insets.consumeAll(typeMask) + } + private fun loadImage(url: Uri?) { ImageRequest.Builder(this) .data(url) .memoryCachePolicy(CachePolicy.DISABLED) .lifecycle(this) .listener(this) - .mangaSourceExtra(MangaSource(intent.getStringExtra(EXTRA_SOURCE))) + .mangaSourceExtra(MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE))) .target(SsivTarget(viewBinding.ssiv)) .enqueueWith(coil) } @@ -142,7 +147,7 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene button.setImageDrawable( CircularProgressDrawable(this).also { it.setStyle(CircularProgressDrawable.LARGE) - it.setColorSchemeColors(getThemeColor(com.google.android.material.R.attr.colorControlNormal)) + it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal)) it.start() }, ) @@ -175,15 +180,4 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene } } } - - companion object { - - const val EXTRA_SOURCE = "source" - - fun newIntent(context: Context, url: String, source: MangaSource?): Intent { - return Intent(context, ImageActivity::class.java) - .setData(Uri.parse(url)) - .putExtra(EXTRA_SOURCE, source?.name) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt index 8839a6416..5b233f281 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt @@ -13,7 +13,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call @@ -35,9 +35,9 @@ class ImageViewModel @Inject constructor( launchLoadingJob(Dispatchers.Default) { val request = ImageRequest.Builder(context) .memoryCachePolicy(CachePolicy.READ_ONLY) - .data(savedStateHandle.require(BaseActivity.EXTRA_DATA)) + .data(savedStateHandle.require(AppRouter.KEY_DATA)) .memoryCachePolicy(CachePolicy.DISABLED) - .mangaSourceExtra(MangaSource(savedStateHandle[ImageActivity.EXTRA_SOURCE])) + .mangaSourceExtra(MangaSource(savedStateHandle[AppRouter.KEY_SOURCE])) .build() val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap() runInterruptible(Dispatchers.IO) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt index 71f6a4cf0..8239ca66c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt @@ -60,6 +60,20 @@ sealed interface ListFilterOption { get() = name } + data class Branch( + override val titleText: String?, + ) : ListFilterOption { + + override val titleResId: Int + get() = if (titleText == null) R.string.system_default else 0 + + override val iconResId: Int + get() = R.drawable.ic_language + + override val groupKey: String + get() = "_branch" + } + data class Tag( val tag: MangaTag ) : ListFilterOption { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListMapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListMapper.kt index 802833859..48c0a40c2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListMapper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListMapper.kt @@ -1,7 +1,9 @@ package org.koitharu.kotatsu.list.domain +import android.annotation.SuppressLint import android.content.Context import androidx.annotation.ColorRes +import androidx.annotation.IntDef import androidx.collection.MutableScatterSet import androidx.collection.ScatterSet import dagger.hilt.android.qualifiers.ApplicationContext @@ -15,6 +17,7 @@ import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel import org.koitharu.kotatsu.list.ui.model.MangaGridModel import org.koitharu.kotatsu.list.ui.model.MangaListModel +import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.tracker.domain.TrackingRepository @@ -28,70 +31,90 @@ class MangaListMapper @Inject constructor( private val trackingRepository: TrackingRepository, private val historyRepository: HistoryRepository, private val favouritesRepository: FavouritesRepository, + private val localMangaIndex: LocalMangaIndex, ) { private val dict by lazy { readTagsDict(context) } - suspend fun toListModelList(manga: Collection, mode: ListMode): List = manga.map { - toListModel(it, mode) + suspend fun toListModelList( + manga: Collection, + mode: ListMode, + @Flags flags: Int = DEFAULTS, + ): List { + val options = getOptions(flags) + return manga.map { toListModelImpl(it, mode, options) } } suspend fun toListModelList( destination: MutableCollection, manga: Collection, - mode: ListMode - ) = manga.mapTo(destination) { - toListModel(it, mode) + mode: ListMode, + @Flags flags: Int = DEFAULTS, + ) { + val options = getOptions(flags) + manga.mapTo(destination) { + toListModelImpl(it, mode, options) + } } - suspend fun toListModel(manga: Manga, mode: ListMode): MangaListModel = when (mode) { - ListMode.LIST -> toCompactListModel(manga) - ListMode.DETAILED_LIST -> toDetailedListModel(manga) - ListMode.GRID -> toGridModel(manga) + suspend fun toListModel( + manga: Manga, + mode: ListMode, + @Flags flags: Int = DEFAULTS, + ): MangaListModel = toListModelImpl(manga, mode, getOptions(flags)) + + fun mapTags(tags: Collection) = tags.map { + ChipsView.ChipModel( + tint = getTagTint(it), + title = it.title, + data = it, + ) } - suspend fun toCompactListModel(manga: Manga) = MangaCompactListModel( + private suspend fun toCompactListModel(manga: Manga, @Options options: Int) = MangaCompactListModel( id = manga.id, title = manga.title, subtitle = manga.tags.joinToString(", ") { it.title }, coverUrl = manga.coverUrl, manga = manga, - counter = getCounter(manga.id), - progress = getProgress(manga.id), - isFavorite = isFavorite(manga.id), + counter = getCounter(manga.id, options), ) - suspend fun toDetailedListModel(manga: Manga) = MangaDetailedListModel( + private suspend fun toDetailedListModel(manga: Manga, @Options options: Int) = MangaDetailedListModel( id = manga.id, title = manga.title, subtitle = manga.altTitle, coverUrl = manga.coverUrl, manga = manga, - counter = getCounter(manga.id), - progress = getProgress(manga.id), - isFavorite = isFavorite(manga.id), + counter = getCounter(manga.id, options), + progress = getProgress(manga.id, options), + isFavorite = isFavorite(manga.id, options), + isSaved = isSaved(manga.id, options), tags = mapTags(manga.tags), ) - suspend fun toGridModel(manga: Manga) = MangaGridModel( + private suspend fun toGridModel(manga: Manga, @Options options: Int) = MangaGridModel( id = manga.id, title = manga.title, coverUrl = manga.coverUrl, manga = manga, - counter = getCounter(manga.id), - progress = getProgress(manga.id), - isFavorite = isFavorite(manga.id), + counter = getCounter(manga.id, options), + progress = getProgress(manga.id, options), + isFavorite = isFavorite(manga.id, options), + isSaved = isSaved(manga.id, options), ) - fun mapTags(tags: Collection) = tags.map { - ChipsView.ChipModel( - tint = getTagTint(it), - title = it.title, - data = it, - ) + private suspend fun toListModelImpl( + manga: Manga, + mode: ListMode, + @Options options: Int + ): MangaListModel = when (mode) { + ListMode.LIST -> toCompactListModel(manga, options) + ListMode.DETAILED_LIST -> toDetailedListModel(manga, options) + ListMode.GRID -> toGridModel(manga, options) } - private suspend fun getCounter(mangaId: Long): Int { + private suspend fun getCounter(mangaId: Long, @Options options: Int): Int { return if (settings.isTrackerEnabled) { trackingRepository.getNewChaptersCount(mangaId) } else { @@ -99,12 +122,20 @@ class MangaListMapper @Inject constructor( } } - private suspend fun getProgress(mangaId: Long): ReadingProgress? { - return historyRepository.getProgress(mangaId, settings.progressIndicatorMode) + private suspend fun getProgress(mangaId: Long, @Options options: Int): ReadingProgress? { + return if (options.isBadgeEnabled(PROGRESS)) { + historyRepository.getProgress(mangaId, settings.progressIndicatorMode) + } else { + null + } } - private fun isFavorite(mangaId: Long): Boolean { - return false // TODO favouritesRepository.isFavorite(mangaId) + private suspend fun isFavorite(mangaId: Long, @Options options: Int): Boolean { + return options.isBadgeEnabled(FAVORITE) && favouritesRepository.isFavorite(mangaId) + } + + private suspend fun isSaved(mangaId: Long, @Options options: Int): Boolean { + return options.isBadgeEnabled(SAVED) && mangaId in localMangaIndex } @ColorRes @@ -128,4 +159,35 @@ class MangaListMapper @Inject constructor( set.trim() set } + + private fun Int.isBadgeEnabled(@Options badge: Int) = this and badge == badge + + @Options + @SuppressLint("WrongConstant") + private fun getOptions(@Flags flags: Int): Int { + var options = settings.getMangaListBadges() or PROGRESS + options = options and flags.inv() + return options + } + + @IntDef(DEFAULTS, NO_SAVED, NO_PROGRESS, NO_FAVORITE, flag = true) + @Retention(AnnotationRetention.SOURCE) + annotation class Flags + + @IntDef(NONE, SAVED, FAVORITE, PROGRESS) + @Retention(AnnotationRetention.SOURCE) + private annotation class Options + + companion object { + + private const val NONE = 0 + private const val SAVED = 1 + private const val PROGRESS = 2 + private const val FAVORITE = 4 + + const val DEFAULTS = NONE + const val NO_SAVED = SAVED + const val NO_PROGRESS = PROGRESS + const val NO_FAVORITE = FAVORITE + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt index bb1631667..ad9d2dcf6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt @@ -3,8 +3,8 @@ package org.koitharu.kotatsu.list.domain import androidx.collection.ArraySet import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import org.koitharu.kotatsu.core.model.toChipModel import org.koitharu.kotatsu.core.prefs.AppSettings -import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.list.ui.model.QuickFilter import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy @@ -52,14 +52,7 @@ abstract class MangaListQuickFilter( return null } val availableOptions = availableFilterOptions.getOrNull()?.map { option -> - ChipsView.ChipModel( - title = option.titleText, - titleResId = option.titleResId, - icon = option.iconResId, - iconData = option.getIconData(), - isChecked = option in selectedOptions, - data = option, - ) + option.toChipModel(isChecked = option in selectedOptions) }.orEmpty() return if (availableOptions.isNotEmpty()) { QuickFilter(availableOptions) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ReadingProgress.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ReadingProgress.kt index 4ce030bf0..e836e0966 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ReadingProgress.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ReadingProgress.kt @@ -31,7 +31,7 @@ data class ReadingProgress( CHAPTERS_LEFT -> totalChapters > 0 && percent in 0f..1f } - fun isCompleted() = Companion.isCompleted(percent) + fun isCompleted() = isCompleted(percent) fun isReversed() = mode == PERCENT_LEFT || mode == CHAPTERS_LEFT @@ -39,9 +39,10 @@ data class ReadingProgress( const val PROGRESS_NONE = -1f const val PROGRESS_COMPLETED = 1f + private const val PROGRESS_COMPLETED_THRESHOLD = 0.99999f fun isValid(percent: Float) = percent in 0f..1f - fun isCompleted(percent: Float) = percent >= PROGRESS_COMPLETED + fun isCompleted(percent: Float) = percent >= PROGRESS_COMPLETED_THRESHOLD } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt index 2c1cd08b2..ab71a388a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt @@ -7,14 +7,12 @@ import android.view.MenuInflater import android.view.MenuItem import android.view.View import android.view.ViewGroup -import android.view.ViewGroup.MarginLayoutParams import androidx.annotation.CallSuper import androidx.appcompat.view.ActionMode import androidx.collection.ArraySet -import androidx.core.graphics.Insets -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import androidx.recyclerview.widget.GridLayoutManager +import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint @@ -24,6 +22,7 @@ import org.koitharu.kotatsu.alternatives.ui.AutoFixService import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseFragment @@ -33,20 +32,17 @@ import org.koitharu.kotatsu.core.ui.list.FitHeightLinearLayoutManager import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller +import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate -import org.koitharu.kotatsu.core.util.ext.measureHeight import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.resolveDp import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.databinding.FragmentListBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment -import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.domain.QuickFilterListener import org.koitharu.kotatsu.list.ui.adapter.ListItemType @@ -57,12 +53,9 @@ import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver -import org.koitharu.kotatsu.main.ui.MainActivity import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder import org.koitharu.kotatsu.search.ui.MangaListActivity import javax.inject.Inject @@ -71,6 +64,7 @@ abstract class MangaListFragment : BaseFragment(), PaginationScrollListener.Callback, MangaListListener, + RecyclerViewOwner, SwipeRefreshLayout.OnRefreshListener, ListSelectionController.Callback, FastScroller.FastScrollListener { @@ -96,6 +90,9 @@ abstract class MangaListFragment : protected val selectedItems: Set get() = collectSelectedItems() + override val recyclerView: RecyclerView? + get() = viewBinding?.recyclerView + override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -125,7 +122,6 @@ abstract class MangaListFragment : isEnabled = isSwipeRefreshEnabled } addMenuProvider(MangaListMenuProvider(this)) - DownloadDialogFragment.registerCallback(this, binding.recyclerView) viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged) viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged) @@ -135,6 +131,19 @@ abstract class MangaListFragment : viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeMask) + val basePadding = v.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) + viewBinding?.recyclerView?.setPadding( + left = barsInsets.left + basePadding, + top = basePadding, + right = barsInsets.right + basePadding, + bottom = barsInsets.bottom + basePadding, + ) + return insets.consumeAll(typeMask) + } + override fun onDestroyView() { listAdapter = null paginationListener = null @@ -147,31 +156,28 @@ abstract class MangaListFragment : override fun onItemClick(item: Manga, view: View) { if (selectionController?.onItemClick(item.id) != true) { if ((activity as? MangaListActivity)?.showPreview(item) != true) { - startActivity(DetailsActivity.newIntent(context ?: return, item)) + router.openDetails(item) } } } override fun onItemLongClick(item: Manga, view: View): Boolean { - return selectionController?.onItemLongClick(view, item.id) ?: false + return selectionController?.onItemLongClick(view, item.id) == true } override fun onItemContextClick(item: Manga, view: View): Boolean { - return selectionController?.onItemContextClick(view, item.id) ?: false + return selectionController?.onItemContextClick(view, item.id) == true } override fun onReadClick(manga: Manga, view: View) { if (selectionController?.onItemClick(manga.id) != true) { - val intent = IntentBuilder(view.context).manga(manga).build() - startActivity(intent) + router.openReader(manga) } } override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { if (selectionController?.onItemClick(manga.id) != true) { - // TODO dialog - val intent = MangaListActivity.newIntent(view.context, tag.source, MangaListFilter(tags = setOf(tag))) - startActivity(intent) + router.showTagDialog(tag) } } @@ -219,24 +225,6 @@ abstract class MangaListFragment : ) } - override fun onWindowInsetsChanged(insets: Insets) { - val rv = requireViewBinding().recyclerView - rv.updatePadding( - bottom = insets.bottom + rv.paddingTop, - ) - rv.fastScroller.updateLayoutParams { - bottomMargin = insets.bottom - } - if (activity is MainActivity) { - val headerHeight = (activity as? AppBarOwner)?.appBar?.measureHeight() ?: insets.top - requireViewBinding().swipeRefreshLayout.setProgressViewOffset( - true, - headerHeight + resources.resolveDp(-72), - headerHeight + resources.resolveDp(10), - ) - } - } - override fun onFilterOptionClick(option: ListFilterOption) { selectionController?.clear() (viewModel as? QuickFilterListener)?.toggleFilterOption(option) @@ -317,13 +305,13 @@ abstract class MangaListFragment : } R.id.action_favourite -> { - FavoriteSheet.show(getChildFragmentManager(), selectedItems) + router.showFavoriteDialog(selectedItems) mode?.finish() true } R.id.action_save -> { - DownloadDialogFragment.show(childFragmentManager, selectedItems) + router.showDownloadDialog(selectedItems, viewBinding?.recyclerView) mode?.finish() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt index 055e79227..832e606e0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt @@ -6,9 +6,9 @@ import android.view.MenuItem import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment -import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet import org.koitharu.kotatsu.list.ui.config.ListConfigSection import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment import org.koitharu.kotatsu.tracker.ui.updates.UpdatesFragment @@ -30,7 +30,7 @@ class MangaListMenuProvider( is UpdatesFragment -> ListConfigSection.Updated else -> ListConfigSection.General } - ListConfigBottomSheet.show(fragment.childFragmentManager, section) + fragment.router.showListConfigSheet(section) true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt index 372edaba8..12d582315 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListViewModel.kt @@ -66,6 +66,7 @@ abstract class MangaListViewModel( key == AppSettings.KEY_PROGRESS_INDICATORS || key == AppSettings.KEY_TRACKER_ENABLED || key == AppSettings.KEY_QUICK_FILTER + || key == AppSettings.KEY_MANGA_LIST_BADGES }.onStart { emit("") }, ) { mode, _ -> mode diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt index de13d0b2b..f14394b46 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt @@ -12,16 +12,19 @@ import com.google.android.material.badge.ExperimentalBadgeUtils import org.koitharu.kotatsu.R import org.koitharu.kotatsu.parsers.util.nullIfEmpty +@Deprecated("") @CheckResult fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? { return bindBadgeImpl(badge, null, counter) } +@Deprecated("") @CheckResult fun View.bindBadge(badge: BadgeDrawable?, text: String?): BadgeDrawable? { return bindBadgeImpl(badge, text, 0) } +@Deprecated("") fun View.clearBadge(badge: BadgeDrawable?) { BadgeUtils.detachBadgeDrawable(badge, this) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ButtonFooterAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ButtonFooterAD.kt new file mode 100644 index 000000000..e301b629b --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ButtonFooterAD.kt @@ -0,0 +1,21 @@ +package org.koitharu.kotatsu.list.ui.adapter + +import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.databinding.ItemButtonFooterBinding +import org.koitharu.kotatsu.list.ui.model.ButtonFooter +import org.koitharu.kotatsu.list.ui.model.ListModel + +fun buttonFooterAD( + listener: ListStateHolderListener, +) = adapterDelegateViewBinding( + { inflater, parent -> ItemButtonFooterBinding.inflate(inflater, parent, false) }, +) { + + binding.button.setOnClickListener { + listener.onFooterButtonClick() + } + + bind { + binding.button.setText(item.textResId) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt index 76fa9f0d7..2c5967262 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt @@ -1,8 +1,10 @@ package org.koitharu.kotatsu.list.ui.adapter +import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import coil3.ImageLoader import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.core.util.ext.disposeImageRequest import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.setTextAndVisible @@ -23,7 +25,13 @@ fun emptyStateListAD( } bind { - binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil) + if (item.icon == 0) { + binding.icon.isVisible = false + binding.icon.disposeImageRequest() + } else { + binding.icon.isVisible = true + binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil) + } binding.textPrimary.setText(item.textPrimary) binding.textSecondary.setTextAndVisible(item.textSecondary) if (listener != null) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt index 2bdf5a434..63f533443 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ErrorFooterAD.kt @@ -7,7 +7,7 @@ import org.koitharu.kotatsu.list.ui.model.ErrorFooter import org.koitharu.kotatsu.list.ui.model.ListModel fun errorFooterAD( - listener: MangaListListener?, + listener: ListStateHolderListener?, ) = adapterDelegateViewBinding( { inflater, parent -> ItemErrorFooterBinding.inflate(inflater, parent, false) }, ) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt index 6e2ef517d..5f91fa887 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListItemType.kt @@ -15,6 +15,7 @@ enum class ListItemType { MANGA_NESTED_GROUP, FOOTER_LOADING, FOOTER_ERROR, + FOOTER_BUTTON, STATE_LOADING, STATE_ERROR, STATE_EMPTY, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListStateHolderListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListStateHolderListener.kt index 0e0d4ad84..44e99f310 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListStateHolderListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/ListStateHolderListener.kt @@ -7,4 +7,6 @@ interface ListStateHolderListener { fun onSecondaryErrorActionClick(error: Throwable) = Unit fun onEmptyActionClick() + + fun onFooterButtonClick() = Unit } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt index 395b294f1..79a37cb1f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt @@ -5,8 +5,8 @@ import androidx.lifecycle.LifecycleOwner import coil3.ImageLoader import coil3.request.allowRgb565 import coil3.request.transformations -import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter @@ -30,7 +30,6 @@ fun mangaGridItemAD( ) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) }, ) { - var badge: BadgeDrawable? = null AdapterDelegateClickListenerAdapter(this, clickListener, MangaGridModel::manga).attach(itemView) sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView) @@ -38,7 +37,12 @@ fun mangaGridItemAD( bind { payloads -> binding.textViewTitle.text = item.title binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads) - binding.imageViewFavorite.isVisible = item.isFavorite + with(binding.iconsView) { + clearIcons() + if (item.isSaved) addIcon(R.drawable.ic_storage) + if (item.isFavorite) addIcon(R.drawable.ic_heart_outline) + isVisible = iconsCount > 0 + } binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { size(CoverSizeResolver(binding.imageViewCover)) defaultPlaceholders(context) @@ -47,6 +51,7 @@ fun mangaGridItemAD( mangaExtra(item.manga) enqueueWith(coil) } - badge = itemView.bindBadge(badge, item.counter) + binding.badge.number = item.counter + binding.badge.isVisible = item.counter > 0 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt index d683521e4..503299e8c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListAdapter.kt @@ -27,5 +27,6 @@ open class MangaListAdapter( addDelegate(ListItemType.QUICK_FILTER, quickFilterAD(listener)) addDelegate(ListItemType.TIP, tipAD(listener)) addDelegate(ListItemType.INFO, infoAD()) + addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(listener)) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt index f56a8916f..52e4e1303 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt @@ -1,11 +1,12 @@ package org.koitharu.kotatsu.list.ui.adapter +import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import coil3.ImageLoader import coil3.request.allowRgb565 import coil3.request.transformations -import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding +import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter @@ -26,7 +27,6 @@ fun mangaListDetailedItemAD( ) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) }, ) { - var badge: BadgeDrawable? = null AdapterDelegateClickListenerAdapter(this, clickListener, MangaDetailedListModel::manga).attach(itemView) @@ -37,6 +37,12 @@ fun mangaListDetailedItemAD( value = item.progress, animate = ListModelDiffCallback.PAYLOAD_PROGRESS_CHANGED in payloads, ) + with(binding.iconsView) { + clearIcons() + if (item.isSaved) addIcon(R.drawable.ic_storage) + if (item.isFavorite) addIcon(R.drawable.ic_heart_outline) + isVisible = iconsCount > 0 + } binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run { size(CoverSizeResolver(binding.imageViewCover)) defaultPlaceholders(context) @@ -46,6 +52,7 @@ fun mangaListDetailedItemAD( enqueueWith(coil) } binding.textViewTags.text = item.tags.joinToString(separator = ", ") { it.title ?: "" } - badge = itemView.bindBadge(badge, item.counter) + binding.badge.number = item.counter + binding.badge.isVisible = item.counter > 0 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt index a11da7f82..c8ff4a504 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt @@ -1,10 +1,10 @@ package org.koitharu.kotatsu.list.ui.adapter +import androidx.core.view.isVisible import androidx.lifecycle.LifecycleOwner import coil3.ImageLoader import coil3.request.allowRgb565 import coil3.request.transformations -import com.google.android.material.badge.BadgeDrawable import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.core.ui.image.TrimTransformation import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter @@ -26,7 +26,6 @@ fun mangaListItemAD( ) = adapterDelegateViewBinding( { inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) }, ) { - var badge: BadgeDrawable? = null AdapterDelegateClickListenerAdapter(this, clickListener, MangaCompactListModel::manga).attach(itemView) @@ -40,6 +39,7 @@ fun mangaListItemAD( mangaExtra(item.manga) enqueueWith(coil) } - badge = itemView.bindBadge(badge, item.counter) + binding.badge.number = item.counter + binding.badge.isVisible = item.counter > 0 } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt index b56d5a1af..17bb3dcf7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/TypedListSpacingDecoration.kt @@ -54,6 +54,7 @@ class TypedListSpacingDecoration( ListItemType.FOOTER_LOADING, ListItemType.FOOTER_ERROR, + ListItemType.FOOTER_BUTTON, ListItemType.STATE_LOADING, ListItemType.STATE_ERROR, ListItemType.STATE_EMPTY, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt index bc7c45a5b..8876eccb4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt @@ -7,8 +7,9 @@ import android.view.ViewGroup import android.widget.AdapterView import android.widget.ArrayAdapter import android.widget.CompoundButton +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible -import androidx.fragment.app.FragmentManager +import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import com.google.android.material.button.MaterialButtonToggleGroup import com.google.android.material.slider.Slider @@ -16,9 +17,8 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.setValueRounded -import org.koitharu.kotatsu.core.util.ext.showDistinct -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter import org.koitharu.kotatsu.databinding.SheetListModeBinding @@ -76,6 +76,14 @@ class ListConfigBottomSheet : } } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + viewBinding?.scrollView?.updatePadding( + bottom = insets.getInsets(typeMask).bottom, + ) + return insets.consume(v, typeMask, bottom = true) + } + override fun onButtonChecked(group: MaterialButtonToggleGroup?, checkedId: Int, isChecked: Boolean) { if (!isChecked) { return @@ -113,14 +121,4 @@ class ListConfigBottomSheet : } override fun onNothingSelected(parent: AdapterView<*>?) = Unit - - companion object { - - private const val TAG = "ListModeSelectDialog" - const val ARG_SECTION = "section" - - fun show(fm: FragmentManager, section: ListConfigSection) = ListConfigBottomSheet().withArgs(1) { - putParcelable(ARG_SECTION, section) - }.showDistinct(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt index c82a39e69..75297e9f0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.config import androidx.lifecycle.SavedStateHandle import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.runBlocking +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -21,7 +22,7 @@ class ListConfigViewModel @Inject constructor( private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { - val section = savedStateHandle.require(ListConfigBottomSheet.ARG_SECTION) + val section = savedStateHandle.require(AppRouter.KEY_LIST_SECTION) var listMode: ListMode get() = when (section) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ButtonFooter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ButtonFooter.kt new file mode 100644 index 000000000..f39a4e323 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ButtonFooter.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.list.ui.model + +import androidx.annotation.StringRes + +data class ButtonFooter( + @StringRes val textResId: Int, +) : ListModel { + + override fun areItemsTheSame(other: ListModel): Boolean { + return other is ButtonFooter && textResId == other.textResId + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt index 6f526eaec..940dec657 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt @@ -4,7 +4,6 @@ import android.content.Context import androidx.annotation.StringRes import org.koitharu.kotatsu.core.ui.model.DateTimeAgo -@ExposedCopyVisibility data class ListHeader private constructor( private val textRaw: Any, @StringRes val buttonTextRes: Int, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaCompactListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaCompactListModel.kt index d7c122492..0a9489ed0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaCompactListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaCompactListModel.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.list.ui.model -import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.parsers.model.Manga data class MangaCompactListModel( @@ -10,6 +9,4 @@ data class MangaCompactListModel( override val coverUrl: String?, override val manga: Manga, override val counter: Int, - override val progress: ReadingProgress?, - override val isFavorite: Boolean, ) : MangaListModel() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaDetailedListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaDetailedListModel.kt index ee83aa9b8..d1a8ad739 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaDetailedListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaDetailedListModel.kt @@ -2,6 +2,8 @@ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.list.domain.ReadingProgress +import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED +import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED import org.koitharu.kotatsu.parsers.model.Manga data class MangaDetailedListModel( @@ -11,7 +13,19 @@ data class MangaDetailedListModel( override val coverUrl: String?, override val manga: Manga, override val counter: Int, - override val progress: ReadingProgress?, - override val isFavorite: Boolean, + val progress: ReadingProgress?, + val isFavorite: Boolean, + val isSaved: Boolean, val tags: List, -) : MangaListModel() +) : MangaListModel() { + + override fun getChangePayload(previousState: ListModel): Any? = when { + previousState !is MangaDetailedListModel || previousState.manga != manga -> null + + previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED + previousState.isFavorite != isFavorite || + previousState.isSaved != isSaved -> PAYLOAD_ANYTHING_CHANGED + + else -> super.getChangePayload(previousState) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt index 52007c3b5..120149e91 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt @@ -1,6 +1,8 @@ package org.koitharu.kotatsu.list.ui.model import org.koitharu.kotatsu.list.domain.ReadingProgress +import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED +import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED import org.koitharu.kotatsu.parsers.model.Manga data class MangaGridModel( @@ -9,6 +11,18 @@ data class MangaGridModel( override val coverUrl: String?, override val manga: Manga, override val counter: Int, - override val progress: ReadingProgress?, - override val isFavorite: Boolean, -) : MangaListModel() + val progress: ReadingProgress?, + val isFavorite: Boolean, + val isSaved: Boolean, +) : MangaListModel() { + + override fun getChangePayload(previousState: ListModel): Any? = when { + previousState !is MangaGridModel || previousState.manga != manga -> null + + previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED + previousState.isFavorite != isFavorite || + previousState.isSaved != isSaved -> PAYLOAD_ANYTHING_CHANGED + + else -> super.getChangePayload(previousState) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt index 4afb11370..1be7efdd6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt @@ -1,8 +1,6 @@ package org.koitharu.kotatsu.list.ui.model -import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_ANYTHING_CHANGED -import org.koitharu.kotatsu.list.ui.ListModelDiffCallback.Companion.PAYLOAD_PROGRESS_CHANGED import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource @@ -13,8 +11,6 @@ sealed class MangaListModel : ListModel { abstract val title: String abstract val coverUrl: String? abstract val counter: Int - abstract val isFavorite: Boolean - abstract val progress: ReadingProgress? val source: MangaSource get() = manga.source @@ -25,10 +21,7 @@ sealed class MangaListModel : ListModel { override fun getChangePayload(previousState: ListModel): Any? = when { previousState !is MangaListModel || previousState.manga != manga -> null - - previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED - previousState.isFavorite != isFavorite || previousState.counter != counter -> PAYLOAD_ANYTHING_CHANGED - + previousState.counter != counter -> PAYLOAD_ANYTHING_CHANGED else -> null } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt index 2010c045c..8fb488e50 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt @@ -5,8 +5,8 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView -import androidx.core.graphics.Insets import androidx.core.text.method.LinkMovementMethodCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.fragment.app.viewModels import coil3.ImageLoader @@ -21,6 +21,7 @@ import coil3.util.CoilUtils import com.google.android.material.chip.Chip import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver import org.koitharu.kotatsu.core.ui.widgets.ChipsView @@ -30,17 +31,12 @@ import org.koitharu.kotatsu.core.util.ext.drawable import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.FragmentPreviewBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.filter.ui.FilterCoordinator -import org.koitharu.kotatsu.image.ui.ImageActivity import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty -import org.koitharu.kotatsu.reader.ui.ReaderActivity import org.koitharu.kotatsu.search.ui.MangaListActivity import javax.inject.Inject @@ -73,48 +69,33 @@ class PreviewFragment : BaseFragment(), View.OnClickList viewModel.description.observe(viewLifecycleOwner, ::onDescriptionChanged) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets + override fun onClick(v: View) { val manga = viewModel.manga.value when (v.id) { R.id.button_close -> closeSelf() - R.id.button_open -> startActivity( - DetailsActivity.newIntent(v.context, manga), - ) - - R.id.button_read -> { - startActivity( - ReaderActivity.IntentBuilder(v.context) - .manga(manga) - .build(), - ) - } + R.id.button_open -> router.openDetails(manga) + R.id.button_read -> router.openReader(manga) - R.id.textView_author -> startActivity( - MangaListActivity.newIntent( - context = v.context, - source = manga.source, - filter = MangaListFilter(query = manga.author), - ), + R.id.textView_author -> router.openSearch( + source = manga.source, + query = manga.author ?: return, ) - R.id.imageView_cover -> startActivity( - ImageActivity.newIntent( - v.context, - manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } ?: return, - manga.source, - ), - scaleUpActivityOptionsOf(v), + R.id.imageView_cover -> router.openImage( + url = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } ?: return, + source = manga.source, + anchor = v, ) } } - override fun onWindowInsetsChanged(insets: Insets) = Unit - override fun onChipClick(chip: Chip, data: Any?) { val tag = data as? MangaTag ?: return val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator if (filter == null) { - startActivity(MangaListActivity.newIntent(chip.context, tag.source, MangaListFilter(tags = setOf(tag)))) + router.openList(tag) } else { filter.toggleTag(tag, true) closeSelf() @@ -140,20 +121,14 @@ class PreviewFragment : BaseFragment(), View.OnClickList private fun onFooterUpdated(footer: PreviewViewModel.FooterInfo?) { with(requireViewBinding()) { buttonRead.isEnabled = footer != null - buttonRead.setTitle(if (footer?.isInProgress() == true) R.string._continue else R.string.read) - buttonRead.subtitle = when { - footer == null -> getString(R.string.loading_) - footer.isIncognito -> getString(R.string.incognito_mode) - footer.currentChapter >= 0 -> getString( - R.string.chapter_d_of_d, - footer.currentChapter + 1, - footer.totalChapters, - ) - - footer.totalChapters == 0 -> getString(R.string.no_chapters) - else -> resources.getQuantityString(R.plurals.chapters, footer.totalChapters, footer.totalChapters) - } - buttonRead.setProgress(footer?.percent?.coerceIn(0f, 1f) ?: 0f, true) + buttonRead.setText( + when { + footer == null -> R.string.loading_ + footer.isIncognito == true -> R.string.incognito + footer.isInProgress() == true -> R.string._continue + else -> R.string.read + }, + ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt index 062c1892e..0d1abdb4f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt @@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.transformLatest import kotlinx.coroutines.plus import org.koitharu.kotatsu.core.model.getPreferredBranch import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.require @@ -42,7 +42,7 @@ class PreviewViewModel @Inject constructor( ) : BaseViewModel() { val manga = MutableStateFlow( - savedStateHandle.require(MangaIntent.KEY_MANGA).manga, + savedStateHandle.require(AppRouter.KEY_MANGA).manga, ) val footer = combine( @@ -54,7 +54,7 @@ class PreviewViewModel @Inject constructor( return@combine null } val b = m.getPreferredBranch(history) - val chapters = m.getChapters(b).orEmpty() + val chapters = m.getChapters(b) FooterInfo( percent = history?.percent ?: PROGRESS_NONE, currentChapter = history?.chapterId?.let { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt index 5f05be1d9..c381bfcd1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalMangaRepository.kt @@ -17,7 +17,9 @@ import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.AlphanumComparator import org.koitharu.kotatsu.core.util.ext.deleteAwait +import org.koitharu.kotatsu.core.util.ext.isWriteable import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.core.util.ext.withChildren import org.koitharu.kotatsu.local.data.index.LocalMangaIndex import org.koitharu.kotatsu.local.data.input.LocalMangaParser @@ -203,7 +205,7 @@ class LocalMangaRepository @Inject constructor( override suspend fun getRelated(seed: Manga): List = emptyList() suspend fun getOutputDir(manga: Manga, fallback: File?): File? { - val defaultDir = fallback ?: storageManager.getDefaultWriteableDir() + val defaultDir = fallback?.takeIfWriteable() ?: storageManager.getDefaultWriteableDir() if (defaultDir != null && LocalMangaOutput.get(defaultDir, manga) != null) { return defaultDir } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt index cb14266e6..626772f43 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/LocalStorageManager.kt @@ -22,7 +22,10 @@ import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.computeSize import org.koitharu.kotatsu.core.util.ext.getStorageName import org.koitharu.kotatsu.core.util.ext.isFileUri +import org.koitharu.kotatsu.core.util.ext.isReadable +import org.koitharu.kotatsu.core.util.ext.isWriteable import org.koitharu.kotatsu.core.util.ext.resolveFile +import org.koitharu.kotatsu.core.util.ext.takeIfWriteable import org.koitharu.kotatsu.parsers.util.mapToSet import java.io.File import javax.inject.Inject @@ -81,8 +84,8 @@ class LocalStorageManager @Inject constructor( } suspend fun getDefaultWriteableDir(): File? = runInterruptible(Dispatchers.IO) { - val preferredDir = settings.mangaStorageDir?.takeIf { it.isWriteable() } - preferredDir ?: getFallbackStorageDir()?.takeIf { it.isWriteable() } + val preferredDir = settings.mangaStorageDir?.takeIfWriteable() + preferredDir ?: getFallbackStorageDir()?.takeIfWriteable() } suspend fun getApplicationStorageDirs(): Set = runInterruptible(Dispatchers.IO) { @@ -184,12 +187,4 @@ class LocalStorageManager @Inject constructor( CACHE_SIZE_MIN } } - - private fun File.isReadable() = runCatching { - canRead() - }.getOrDefault(false) - - private fun File.isWriteable() = runCatching { - canWrite() - }.getOrDefault(false) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt index ae7daef04..d7713b290 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt @@ -12,18 +12,22 @@ import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.isLocal import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.parsers.util.find +import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault +import org.koitharu.kotatsu.parsers.util.json.getEnumValueOrNull import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault import org.koitharu.kotatsu.parsers.util.json.getStringOrNull import org.koitharu.kotatsu.parsers.util.json.mapJSONToSet +import org.koitharu.kotatsu.parsers.util.json.toStringSet +import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toTitleCase import java.io.File @@ -34,120 +38,124 @@ class MangaIndex(source: String?) { fun setMangaInfo(manga: Manga) { require(!manga.isLocal) { "Local manga information cannot be stored" } - json.put("id", manga.id) - json.put("title", manga.title) - json.put("title_alt", manga.altTitle) - json.put("url", manga.url) - json.put("public_url", manga.publicUrl) - json.put("author", manga.author) - json.put("cover", manga.coverUrl) - json.put("description", manga.description) - json.put("rating", manga.rating) - json.put("nsfw", manga.isNsfw) - json.put("state", manga.state?.name) - json.put("source", manga.source.name) - json.put("cover_large", manga.largeCoverUrl) + json.put(KEY_ID, manga.id) + json.put(KEY_TITLE, manga.title) + json.put(KEY_TITLE_ALT, manga.altTitle) // for backward compatibility + json.put(KEY_ALT_TITLES, JSONArray(manga.altTitles)) + json.put(KEY_URL, manga.url) + json.put(KEY_PUBLIC_URL, manga.publicUrl) + json.put(KEY_AUTHOR, manga.author) // for backward compatibility + json.put(KEY_AUTHORS, JSONArray(manga.authors)) + json.put(KEY_COVER, manga.coverUrl) + json.put(KEY_DESCRIPTION, manga.description) + json.put(KEY_RATING, manga.rating) + json.put(KEY_CONTENT_RATING, manga.contentRating) + json.put(KEY_NSFW, manga.isNsfw) // for backward compatibility + json.put(KEY_STATE, manga.state?.name) + json.put(KEY_SOURCE, manga.source.name) + json.put(KEY_COVER_LARGE, manga.largeCoverUrl) json.put( - "tags", + KEY_TAGS, JSONArray().also { a -> for (tag in manga.tags) { val jo = JSONObject() - jo.put("key", tag.key) - jo.put("title", tag.title) + jo.put(KEY_KEY, tag.key) + jo.put(KEY_TITLE, tag.title) a.put(jo) } }, ) - if (!json.has("chapters")) { - json.put("chapters", JSONObject()) + if (!json.has(KEY_CHAPTERS)) { + json.put(KEY_CHAPTERS, JSONObject()) } - json.put("app_id", BuildConfig.APPLICATION_ID) - json.put("app_version", BuildConfig.VERSION_CODE) + json.put(KEY_APP_ID, BuildConfig.APPLICATION_ID) + json.put(KEY_APP_VERSION, BuildConfig.VERSION_CODE) } fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching { - val source = MangaSource(json.getString("source")) + val source = MangaSource(json.getString(KEY_SOURCE)) Manga( - id = json.getLong("id"), - title = json.getString("title"), - altTitle = json.getStringOrNull("title_alt"), - url = json.getString("url"), - publicUrl = json.getStringOrNull("public_url").orEmpty(), - author = json.getStringOrNull("author"), - largeCoverUrl = json.getStringOrNull("cover_large"), + id = json.getLong(KEY_ID), + title = json.getString(KEY_TITLE), + altTitles = json.optJSONArray(KEY_ALT_TITLES)?.toStringSet() + ?: setOfNotNull(json.getStringOrNull(KEY_TITLE_ALT)), + url = json.getString(KEY_URL), + publicUrl = json.getStringOrNull(KEY_PUBLIC_URL).orEmpty(), + authors = json.optJSONArray(KEY_AUTHORS)?.toStringSet() + ?: setOfNotNull(json.getStringOrNull(KEY_AUTHOR)), + largeCoverUrl = json.getStringOrNull(KEY_COVER_LARGE), source = source, - rating = json.getDouble("rating").toFloat(), - isNsfw = json.getBooleanOrDefault("nsfw", false), - coverUrl = json.getString("cover"), - state = json.getStringOrNull("state")?.let { stateString -> - MangaState.entries.find(stateString) - }, - description = json.getStringOrNull("description"), - tags = json.getJSONArray("tags").mapJSONToSet { x -> + rating = json.getFloatOrDefault(KEY_RATING, RATING_UNKNOWN), + contentRating = json.getEnumValueOrNull(KEY_CONTENT_RATING, ContentRating::class.java) + ?: if (json.getBooleanOrDefault(KEY_NSFW, false)) ContentRating.ADULT else null, + coverUrl = json.getStringOrNull(KEY_COVER), + state = json.getEnumValueOrNull(KEY_STATE, MangaState::class.java), + description = json.getStringOrNull(KEY_DESCRIPTION), + tags = json.getJSONArray(KEY_TAGS).mapJSONToSet { x -> MangaTag( - title = x.getString("title").toTitleCase(), - key = x.getString("key"), + title = x.getString(KEY_TITLE).toTitleCase(), + key = x.getString(KEY_KEY), source = source, ) }, - chapters = getChapters(json.getJSONObject("chapters"), source), + chapters = getChapters(json.getJSONObject(KEY_CHAPTERS), source), ) }.getOrNull() - fun getCoverEntry(): String? = json.getStringOrNull("cover_entry") + fun getCoverEntry(): String? = json.getStringOrNull(KEY_COVER_ENTRY) fun addChapter(chapter: IndexedValue, filename: String?) { - val chapters = json.getJSONObject("chapters") + val chapters = json.getJSONObject(KEY_CHAPTERS) if (!chapters.has(chapter.value.id.toString())) { val jo = JSONObject() - jo.put("number", chapter.value.number) - jo.put("volume", chapter.value.volume) - jo.put("url", chapter.value.url) - jo.put("name", chapter.value.name) - jo.put("uploadDate", chapter.value.uploadDate) - jo.put("scanlator", chapter.value.scanlator) - jo.put("branch", chapter.value.branch) - jo.put("entries", "%08d_%03d\\d{3}".format(chapter.value.branch.hashCode(), chapter.index + 1)) - jo.put("file", filename) + jo.put(KEY_NUMBER, chapter.value.number) + jo.put(KEY_VOLUME, chapter.value.volume) + jo.put(KEY_URL, chapter.value.url) + jo.put(KEY_NAME, chapter.value.title.orEmpty()) + jo.put(KEY_UPLOAD_DATE, chapter.value.uploadDate) + jo.put(KEY_SCANLATOR, chapter.value.scanlator) + jo.put(KEY_BRANCH, chapter.value.branch) + jo.put(KEY_ENTRIES, "%08d_%04d\\d{4}".format(chapter.value.branch.hashCode(), chapter.index + 1)) + jo.put(KEY_FILE, filename) chapters.put(chapter.value.id.toString(), jo) } } fun removeChapter(id: Long): Boolean { - return json.has("chapters") && json.getJSONObject("chapters").remove(id.toString()) != null + return json.has(KEY_CHAPTERS) && json.getJSONObject(KEY_CHAPTERS).remove(id.toString()) != null } fun getChapterFileName(chapterId: Long): String? { - return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file") + return json.optJSONObject(KEY_CHAPTERS)?.optJSONObject(chapterId.toString())?.getStringOrNull(KEY_FILE) } fun setCoverEntry(name: String) { - json.put("cover_entry", name) + json.put(KEY_COVER_ENTRY, name) } fun getChapterNamesPattern(chapter: MangaChapter) = Regex( - json.getJSONObject("chapters") + json.getJSONObject(KEY_CHAPTERS) .getJSONObject(chapter.id.toString()) - .getString("entries"), + .getString(KEY_ENTRIES), ) fun sortChaptersByName() { - val jo = json.getJSONObject("chapters") + val jo = json.getJSONObject(KEY_CHAPTERS) val list = ArrayList(jo.length()) jo.keys().forEach { id -> val item = jo.getJSONObject(id) - item.put("id", id) + item.put(KEY_ID, id) list.add(item) } val comparator = org.koitharu.kotatsu.core.util.AlphanumComparator() - list.sortWith(compareBy(comparator) { it.getString("name") }) + list.sortWith(compareBy(comparator) { it.getString(KEY_NAME) }) val newJo = JSONObject() list.forEachIndexed { i, obj -> - obj.put("number", i + 1) - val id = obj.remove("id") as String + obj.put(KEY_NUMBER, i + 1) + val id = obj.remove(KEY_ID) as String newJo.put(id, obj) } - json.put("chapters", newJo) + json.put(KEY_CHAPTERS, newJo) } fun clear() { @@ -171,13 +179,13 @@ class MangaIndex(source: String?) { chapters.add( MangaChapter( id = k.toLong(), - name = v.getString("name"), - url = v.getString("url"), - number = v.getFloatOrDefault("number", 0f), - volume = v.getIntOrDefault("volume", 0), - uploadDate = v.getLongOrDefault("uploadDate", 0L), - scanlator = v.getStringOrNull("scanlator"), - branch = v.getStringOrNull("branch"), + title = v.getStringOrNull(KEY_NAME), + url = v.getString(KEY_URL), + number = v.getFloatOrDefault(KEY_NUMBER, 0f), + volume = v.getIntOrDefault(KEY_VOLUME, 0), + uploadDate = v.getLongOrDefault(KEY_UPLOAD_DATE, 0L), + scanlator = v.getStringOrNull(KEY_SCANLATOR), + branch = v.getStringOrNull(KEY_BRANCH), source = source, ), ) @@ -193,6 +201,37 @@ class MangaIndex(source: String?) { companion object { + private const val KEY_ID = "id" + private const val KEY_TITLE = "title" + private const val KEY_TITLE_ALT = "title_alt" + private const val KEY_ALT_TITLES = "alt_titles" + private const val KEY_URL = "url" + private const val KEY_PUBLIC_URL = "public_url" + private const val KEY_AUTHOR = "author" + private const val KEY_AUTHORS = "authors" + private const val KEY_COVER = "cover" + private const val KEY_DESCRIPTION = "description" + private const val KEY_RATING = "rating" + private const val KEY_CONTENT_RATING = "content_rating" + private const val KEY_NSFW = "nsfw" + private const val KEY_STATE = "state" + private const val KEY_SOURCE = "source" + private const val KEY_COVER_LARGE = "cover_large" + private const val KEY_TAGS = "tags" + private const val KEY_CHAPTERS = "chapters" + private const val KEY_NUMBER = "number" + private const val KEY_VOLUME = "volume" + private const val KEY_NAME = "name" + private const val KEY_UPLOAD_DATE = "uploadDate" + private const val KEY_SCANLATOR = "scanlator" + private const val KEY_BRANCH = "branch" + private const val KEY_ENTRIES = "entries" + private const val KEY_FILE = "file" + private const val KEY_COVER_ENTRY = "cover_entry" + private const val KEY_KEY = "key" + private const val KEY_APP_ID = "app_id" + private const val KEY_APP_VERSION = "app_version" + @Blocking @WorkerThread fun read(fileSystem: FileSystem, path: Path): MangaIndex? = runCatchingCancellable { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt index 013aa9275..e9f7faf55 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt @@ -15,6 +15,8 @@ import okio.sink import okio.use import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.MimeTypes +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.compressToPNG import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.subdir @@ -59,7 +61,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { } } - suspend fun put(url: String, source: Source, mimeType: String?): File = withContext(Dispatchers.IO) { + suspend fun put(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) { val file = createBufferFile(url, mimeType) try { val bytes = file.sink(append = false).buffer().use { @@ -78,7 +80,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { } suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) { - val file = createBufferFile(url, "image/png") + val file = createBufferFile(url, MimeType("image/png")) try { bitmap.compressToPNG(file) val cache = lruCache.get() @@ -107,9 +109,8 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) { it.printStackTraceDebug() }.getOrDefault(SIZE_DEFAULT) - private suspend fun createBufferFile(url: String, mimeType: String?): File { - val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) } - ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" } + private suspend fun createBufferFile(url: String, mimeType: MimeType?): File { + val ext = MimeTypes.getExtension(mimeType) ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" } val cacheDir = cacheDir.get() val rootDir = checkNotNull(cacheDir.parentFile) { "Cannot get parent for ${cacheDir.absolutePath}" } val name = UUID.randomUUID().toString() + "." + ext diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt index d2425be9e..a81ca5aab 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt @@ -73,6 +73,10 @@ class LocalMangaIndex @Inject constructor( }.getOrNull() } + suspend operator fun contains(mangaId: Long): Boolean { + return db.getLocalMangaIndexDao().findPath(mangaId) != null + } + suspend fun put(manga: LocalManga) = mutex.withLock { db.withTransaction { upsert(manga) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt index 6941c2409..1aaf24aa9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.local.data.input import android.net.Uri -import android.webkit.MimeTypeMap import androidx.core.net.toFile import androidx.core.net.toUri import kotlinx.coroutines.Dispatchers @@ -18,8 +17,11 @@ import okio.openZip import org.jetbrains.annotations.Blocking import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.util.AlphanumComparator +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP +import org.koitharu.kotatsu.core.util.ext.isDirectory import org.koitharu.kotatsu.core.util.ext.isFileUri +import org.koitharu.kotatsu.core.util.ext.isImage import org.koitharu.kotatsu.core.util.ext.isRegularFile import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug @@ -35,6 +37,7 @@ import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.longHashCode import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.parsers.util.toTitleCase import java.io.File /** @@ -54,139 +57,195 @@ class LocalMangaParser(private val uri: Uri) { private val rootFile: File = File(uri.schemeSpecificPart) suspend fun getManga(withDetails: Boolean): LocalManga = runInterruptible(Dispatchers.IO) { - val (fileSystem, rootPath) = uri.resolveFsAndPath() - val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) - val mangaInfo = index?.getMangaInfo() - if (mangaInfo != null) { - val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it } ?: fileSystem.findFirstImage(rootPath) - mangaInfo.copy( - source = LocalMangaSource, - url = rootFile.toUri().toString(), - coverUrl = coverEntry?.let { uri.child(it, resolve = true).toString() }, - largeCoverUrl = null, - chapters = if (withDetails) { - mangaInfo.chapters?.mapNotNull { c -> - val path = index.getChapterFileName(c.id)?.toPath() - if (path != null && !fileSystem.exists(rootPath / path)) { - null - } else { - c.copy( - url = path?.let { - uri.child(it, resolve = false).toString() - } ?: uri.toString(), + (uri.resolveFsAndPath()).use { (fileSystem, rootPath) -> + val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) + val mangaInfo = index?.getMangaInfo() + if (mangaInfo != null) { + val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it } + mangaInfo.copy( + source = LocalMangaSource, + url = rootFile.toUri().toString(), + coverUrl = coverEntry?.let { + uri.child(it, resolve = true).toString() + } ?: fileSystem.findFirstImageUri(rootPath)?.toString(), + largeCoverUrl = null, + chapters = if (withDetails) { + mangaInfo.chapters?.mapNotNull { c -> + val path = index.getChapterFileName(c.id)?.toPath() + if (path != null && !fileSystem.exists(rootPath / path)) { + null + } else { + c.copy( + url = path?.let { + uri.child(it, resolve = false).toString() + } ?: uri.toString(), + source = LocalMangaSource, + ) + } + } + } else { + null + }, + ) + } else { + val title = rootFile.name.fileNameToTitle() + Manga( + id = rootFile.absolutePath.longHashCode(), + title = title, + url = rootFile.toUri().toString(), + publicUrl = rootFile.toUri().toString(), + source = LocalMangaSource, + coverUrl = fileSystem.findFirstImageUri(rootPath)?.toString(), + chapters = if (withDetails) { + val chapters = fileSystem.listRecursively(rootPath) + .mapNotNullTo(HashSet()) { path -> + when { + !fileSystem.isRegularFile(path) -> null + path.isImage() -> path.parent + hasZipExtension(path.name) -> path + else -> null + } + }.sortedWith(compareBy(AlphanumComparator()) { x -> x.toString() }) + chapters.mapIndexed { i, p -> + val s = if (p.root == rootPath.root) { + p.relativeTo(rootPath).toString() + } else { + p + }.toString().removePrefix(Path.DIRECTORY_SEPARATOR) + MangaChapter( + id = "$i$s".longHashCode(), + title = p.userFriendlyName(), + number = 0f, + volume = 0, source = LocalMangaSource, + uploadDate = 0L, + url = uri.child(p.relativeTo(rootPath), resolve = false).toString(), + scanlator = null, + branch = null, ) } - } - } else { - null - }, - ) - } else { - val title = rootFile.name.fileNameToTitle() - val coverEntry = fileSystem.findFirstImage(rootPath) - val mimeTypeMap = MimeTypeMap.getSingleton() - Manga( - id = rootFile.absolutePath.longHashCode(), - title = title, - url = rootFile.toUri().toString(), - publicUrl = rootFile.toUri().toString(), - source = LocalMangaSource, - coverUrl = coverEntry?.let { uri.child(it, resolve = true).toString() }, - chapters = if (withDetails) { - val chapters = fileSystem.listRecursively(rootPath) - .mapNotNullTo(HashSet()) { path -> - when { - path == coverEntry -> null - !fileSystem.isRegularFile(path) -> null - mimeTypeMap.isImage(path) -> path.parent - hasZipExtension(path.name) -> path - else -> null - } - }.sortedWith(compareBy(AlphanumComparator()) { x -> x.toString() }) - chapters.mapIndexed { i, p -> - val s = if (p.root == rootPath.root) { - p.relativeTo(rootPath).toString() - } else { - p - }.toString().removePrefix(Path.DIRECTORY_SEPARATOR) - MangaChapter( - id = "$i$s".longHashCode(), - name = s.fileNameToTitle().ifEmpty { title }, - number = 0f, - volume = 0, - source = LocalMangaSource, - uploadDate = 0L, - url = uri.child(p.relativeTo(rootPath), resolve = false).toString(), - scanlator = null, - branch = null, - ) - } - } else { - null - }, - altTitle = null, - rating = -1f, - isNsfw = false, - tags = setOf(), - state = null, - author = null, - largeCoverUrl = null, - description = null, - ) - }.let { LocalManga(it, rootFile) } + } else { + null + }, + altTitles = emptySet(), + rating = -1f, + contentRating = null, + tags = emptySet(), + state = null, + authors = emptySet(), + largeCoverUrl = null, + description = null, + ) + }.let { LocalManga(it, rootFile) } + } } suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) { - val (fileSystem, rootPath) = uri.resolveFsAndPath() - val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) - index?.getMangaInfo() + uri.resolveFsAndPath().use { (fileSystem, rootPath) -> + val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) + index?.getMangaInfo() + } } suspend fun getPages(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) { val chapterUri = chapter.url.toUri().resolve() - val (fileSystem, rootPath) = chapterUri.resolveFsAndPath() - val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) - val entries = fileSystem.listRecursively(rootPath) - .filter { fileSystem.isRegularFile(it) } - if (index != null) { - val pattern = index.getChapterNamesPattern(chapter) - entries.filter { x -> x.name.substringBefore('.').matches(pattern) } - } else { - val mimeTypeMap = MimeTypeMap.getSingleton() - entries.filter { x -> - mimeTypeMap.isImage(x) && x.parent == rootPath - } - }.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() }) - .map { x -> - val entryUri = chapterUri.child(x, resolve = true).toString() - MangaPage( - id = entryUri.longHashCode(), - url = entryUri, - preview = null, - source = LocalMangaSource, - ) - } + chapterUri.resolveFsAndPath().use { (fileSystem, rootPath) -> + val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX) + val entries = fileSystem.listRecursively(rootPath) + .filter { fileSystem.isRegularFile(it) } + if (index != null) { + val pattern = index.getChapterNamesPattern(chapter) + entries.filter { x -> x.name.substringBefore('.').matches(pattern) } + } else { + entries.filter { x -> x.isImage() && x.parent == rootPath } + }.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() }) + .map { x -> + val entryUri = chapterUri.child(x, resolve = true).toString() + MangaPage( + id = entryUri.longHashCode(), + url = entryUri, + preview = null, + source = LocalMangaSource, + ) + } + } } private fun Uri.child(path: Path, resolve: Boolean): Uri { + val file = fileFromPath() val builder = buildUpon() - if (isZipUri() || !resolve) { + val isZip = isZipUri() || file.isZipArchive + if (isZip) { + builder.scheme(URI_SCHEME_ZIP) + } + if (isZip || !resolve) { builder.fragment(path.toString().removePrefix(Path.DIRECTORY_SEPARATOR)) } else { - val file = toFile() - if (file.isZipArchive) { - builder.fragment(path.toString().removePrefix(Path.DIRECTORY_SEPARATOR)) - builder.scheme(URI_SCHEME_ZIP) - } else { - builder.appendEncodedPath(path.relativeTo(file.toOkioPath()).toString()) - } + builder.appendEncodedPath(path.relativeTo(file.toOkioPath()).toString()) } return builder.build() } + private fun FileSystem.findFirstImageUri( + rootPath: Path, + recursive: Boolean = false + ): Uri? = runCatchingCancellable { + val list = list(rootPath) + for (file in list.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })) { + if (isRegularFile(file)) { + if (file.isImage()) { + return@runCatchingCancellable uri.child(file, resolve = true) + } + if (recursive && file.isZip()) { + openZip(file).use { zipFs -> + zipFs.findFirstImageUri(Path.DIRECTORY_SEPARATOR.toPath())?.let { subUri -> + val subPath = subUri.path.orEmpty().removePrefix(uri.path.orEmpty()) + .replace(REGEX_PARENT_PATH_PREFIX, "") + return@runCatchingCancellable uri.child(file, resolve = true) + .child(subPath.toPath(), resolve = false) + } + } + } + } else if (recursive && isDirectory(file)) { + findFirstImageUri(file, true)?.let { + return@runCatchingCancellable it + } + } + } + if (recursive) { + null + } else { + findFirstImageUri(rootPath, recursive = true) + } + }.onFailure { e -> + e.printStackTraceDebug() + }.getOrNull() + + private fun Path.userFriendlyName(): String = name.substringBeforeLast('.') + .replace('_', ' ') + .toTitleCase() + + private class FsAndPath( + val fileSystem: FileSystem, + val path: Path, + private val isCloseable: Boolean, + ) : AutoCloseable { + + override fun close() { + if (isCloseable) { + fileSystem.close() + } + } + + operator fun component1() = fileSystem + + operator fun component2() = path + } + companion object { + private val REGEX_PARENT_PATH_PREFIX = Regex("^(/\\.\\.)+") + @Blocking fun getOrNull(file: File): LocalMangaParser? = if ((file.isDirectory || file.isZipArchive) && file.canRead()) { LocalMangaParser(file) @@ -207,28 +266,9 @@ class LocalMangaParser(private val uri: Uri) { } }.flowOn(Dispatchers.Default).firstOrNull() - private fun FileSystem.findFirstImage(rootPath: Path) = findFirstImageImpl(rootPath, false) - ?: findFirstImageImpl(rootPath, true) + private fun Path.isImage(): Boolean = MimeTypes.getMimeTypeFromExtension(name)?.isImage == true - private fun FileSystem.findFirstImageImpl( - rootPath: Path, - recursive: Boolean - ): Path? = runCatchingCancellable { - val mimeTypeMap = MimeTypeMap.getSingleton() - if (recursive) { - listRecursively(rootPath) - } else { - list(rootPath).asSequence() - }.filter { isRegularFile(it) && mimeTypeMap.isImage(it) } - .toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() }) - .firstOrNull() - }.onFailure { e -> - e.printStackTraceDebug() - }.getOrNull() - - private fun MimeTypeMap.isImage(path: Path): Boolean = - getMimeTypeFromExtension(path.name.substringAfterLast('.')) - ?.startsWith("image/") == true + private fun Path.isZip(): Boolean = hasZipExtension(name) private fun Uri.resolve(): Uri = if (isFileUri()) { val file = toFile() @@ -243,21 +283,28 @@ class LocalMangaParser(private val uri: Uri) { this } + private fun Uri.fileFromPath(): File = File(requireNotNull(path) { "Uri path is null: $this" }) + @Blocking - private fun Uri.resolveFsAndPath(): Pair { + private fun Uri.resolveFsAndPath(): FsAndPath { val resolved = resolve() return when { - resolved.isZipUri() -> { - FileSystem.SYSTEM.openZip(resolved.schemeSpecificPart.toPath()) to resolved.fragment.orEmpty() - .toRootedPath() - } + resolved.isZipUri() -> FsAndPath( + FileSystem.SYSTEM.openZip(resolved.schemeSpecificPart.toPath()), + resolved.fragment.orEmpty().toRootedPath(), + isCloseable = true, + ) isFileUri() -> { val file = toFile() if (file.isZipArchive) { - FileSystem.SYSTEM.openZip(schemeSpecificPart.toPath()) to fragment.orEmpty().toRootedPath() + FsAndPath( + FileSystem.SYSTEM.openZip(schemeSpecificPart.toPath()), + fragment.orEmpty().toRootedPath(), + isCloseable = true, + ) } else { - FileSystem.SYSTEM to file.toOkioPath() + FsAndPath(FileSystem.SYSTEM, file.toOkioPath(), isCloseable = false) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt index edf715610..5c0202382 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.util.MimeTypes +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.takeIfReadable import org.koitharu.kotatsu.core.util.ext.toFileNameSafe @@ -16,6 +18,7 @@ import org.koitharu.kotatsu.local.data.MangaIndex import org.koitharu.kotatsu.local.data.input.LocalMangaParser import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter +import org.koitharu.kotatsu.parsers.util.nullIfEmpty import java.io.File class LocalMangaDirOutput( @@ -35,10 +38,10 @@ class LocalMangaDirOutput( override suspend fun mergeWithExisting() = Unit - override suspend fun addCover(file: File, ext: String) = mutex.withLock { + override suspend fun addCover(file: File, type: MimeType?) = mutex.withLock { val name = buildString { append("cover") - if (ext.isNotEmpty() && ext.length <= 4) { + MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } @@ -50,14 +53,14 @@ class LocalMangaDirOutput( flushIndex() } - override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = + override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) = mutex.withLock { val output = chaptersOutput.getOrPut(chapter.value) { ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP)) } val name = buildString { append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) - if (ext.isNotEmpty() && ext.length <= 4) { + MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } @@ -143,7 +146,16 @@ class LocalMangaDirOutput( index.getChapterFileName(chapter.value.id)?.let { return it } - val baseName = "${chapter.index}_${chapter.value.name.toFileNameSafe()}".take(32) + val baseName = buildString { + append(chapter.index) + chapter.value.title?.nullIfEmpty()?.let { + append('_') + append(it.toFileNameSafe()) + } + if (length > 32) { + deleteRange(31, lastIndex) + } + } var i = 0 while (true) { val name = (if (i == 0) baseName else baseName + "_$i") + ".cbz" @@ -160,6 +172,6 @@ class LocalMangaDirOutput( companion object { - private const val FILENAME_PATTERN = "%08d_%03d%03d" + private const val FILENAME_PATTERN = "%08d_%04d%04d" } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt index f8ffd8362..6b6672e8a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext import okio.Closeable import org.koitharu.kotatsu.core.prefs.DownloadFormat +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toFileNameSafe import org.koitharu.kotatsu.local.data.input.LocalMangaParser @@ -20,9 +21,9 @@ sealed class LocalMangaOutput( abstract suspend fun mergeWithExisting() - abstract suspend fun addCover(file: File, ext: String) + abstract suspend fun addCover(file: File, type: MimeType?) - abstract suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) + abstract suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) abstract suspend fun flushChapter(chapter: MangaChapter): Boolean diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt index eded64595..3d6767b9a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt @@ -7,6 +7,8 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import okhttp3.internal.closeQuietly import org.koitharu.kotatsu.core.model.isLocal +import org.koitharu.kotatsu.core.util.MimeTypes +import org.koitharu.kotatsu.core.util.ext.MimeType import org.koitharu.kotatsu.core.util.ext.deleteAwait import org.koitharu.kotatsu.core.util.ext.readText import org.koitharu.kotatsu.core.zip.ZipOutput @@ -39,10 +41,10 @@ class LocalMangaZipOutput( } } - override suspend fun addCover(file: File, ext: String) = mutex.withLock { + override suspend fun addCover(file: File, type: MimeType?) = mutex.withLock { val name = buildString { append(FILENAME_PATTERN.format(0, 0, 0)) - if (ext.isNotEmpty() && ext.length <= 4) { + MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } @@ -53,11 +55,11 @@ class LocalMangaZipOutput( index.setCoverEntry(name) } - override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) = + override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) = mutex.withLock { val name = buildString { append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber)) - if (ext.isNotEmpty() && ext.length <= 4) { + MimeTypes.getExtension(type)?.let { ext -> append('.') append(ext) } @@ -116,7 +118,7 @@ class LocalMangaZipOutput( companion object { - private const val FILENAME_PATTERN = "%08d_%03d%03d" + private const val FILENAME_PATTERN = "%08d_%04d%04d" suspend fun filterChapters(file: File, manga: Manga, idsToRemove: Set) = runInterruptible(Dispatchers.IO) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt index fdc21e1d8..08b85061e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt @@ -7,10 +7,10 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.FragmentManager import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.databinding.DialogImportBinding @@ -26,7 +26,7 @@ class ImportDialogFragment : AlertDialogFragment(), View.On private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) { startImport(it) } - private val importDirCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { + private val importDirCall = OpenDocumentTreeHelper(this) { startImport(listOfNotNull(it)) } @@ -74,11 +74,4 @@ class ImportDialogFragment : AlertDialogFragment(), View.On Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show() dismiss() } - - companion object { - - private const val TAG = "ImportDialogFragment" - - fun show(fm: FragmentManager) = ImportDialogFragment().show(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt index e60511cb5..8214ddd57 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt @@ -18,14 +18,16 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ErrorReporterReceiver +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra +import org.koitharu.kotatsu.core.util.ext.powerManager import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.toUriOrNull -import org.koitharu.kotatsu.details.ui.DetailsActivity +import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.runCatchingCancellable @@ -50,12 +52,14 @@ class ImportService : CoroutineIntentService() { override suspend fun IntentJobContext.processIntent(intent: Intent) { val uri = requireNotNull(intent.getStringExtra(DATA_URI)?.toUriOrNull()) { "No input uri" } startForeground(this) - val result = runCatchingCancellable { - importer.import(uri).manga - } - if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { - val notification = buildNotification(result) - notificationManager.notify(TAG, startId, notification) + powerManager.withPartialWakeLock(TAG) { + val result = runCatchingCancellable { + importer.import(uri).manga + } + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val notification = buildNotification(result) + notificationManager.notify(TAG, startId, notification) + } } } @@ -113,7 +117,7 @@ class ImportService : CoroutineIntentService() { ).toBitmapOrNull(), ) notification.setSubText(manga.title) - val intent = DetailsActivity.newIntent(applicationContext, manga) + val intent = AppRouter.detailsIntent(applicationContext, manga) notification.setContentIntent( PendingIntentCompat.getActivity( applicationContext, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt index 6c4625f31..a121d7b33 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalChaptersRemoveService.kt @@ -17,6 +17,8 @@ import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.ui.CoroutineIntentService import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat +import org.koitharu.kotatsu.core.util.ext.powerManager +import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.domain.model.LocalManga @@ -44,12 +46,14 @@ class LocalChaptersRemoveService : CoroutineIntentService() { } override suspend fun IntentJobContext.processIntent(intent: Intent) { + startForeground(this) val manga = intent.getParcelableExtraCompat(EXTRA_MANGA)?.manga ?: return val chaptersIds = intent.getLongArrayExtra(EXTRA_CHAPTERS_IDS)?.toSet() ?: return - startForeground(this) - val mangaWithChapters = localMangaRepository.getDetails(manga) - localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds) - localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga))) + powerManager.withPartialWakeLock(TAG) { + val mangaWithChapters = localMangaRepository.getDetails(manga) + localMangaRepository.deleteChapters(mangaWithChapters, chaptersIds) + localStorageChanges.emit(LocalManga(localMangaRepository.getDetails(manga))) + } } override fun IntentJobContext.onError(error: Throwable) { @@ -63,7 +67,7 @@ class LocalChaptersRemoveService : CoroutineIntentService() { .setAutoCancel(true) .setContentIntent(ErrorReporterReceiver.getPendingIntent(applicationContext, error)) .build() - val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val nm = getSystemService(NOTIFICATION_SERVICE) as NotificationManager nm.notify(NOTIFICATION_ID + startId, notification) } @@ -104,6 +108,8 @@ class LocalChaptersRemoveService : CoroutineIntentService() { private const val EXTRA_MANGA = "manga" private const val EXTRA_CHAPTERS_IDS = "chapters_ids" + private const val TAG = CHANNEL_ID + fun start(context: Context, manga: Manga, chaptersIds: Collection) { if (chaptersIds.isEmpty()) { return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt index af638e699..d781020d6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt @@ -16,6 +16,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.util.ShareHelper @@ -25,12 +26,10 @@ import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator -import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.remotelist.ui.MangaSearchMenuProvider import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract -import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { @@ -68,11 +67,11 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { } override fun onEmptyActionClick() { - ImportDialogFragment.show(getChildFragmentManager()) + router.showImportDialog() } override fun onFilterClick(view: View?) { - FilterSheetFragment.show(getChildFragmentManager()) + router.showFilterSheet() } override fun onPrimaryButtonClick(tipView: TipView) { @@ -82,7 +81,7 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner { } override fun onSecondaryButtonClick(tipView: TipView) { - startActivity(MangaDirectoriesActivity.newIntent(tipView.context)) + router.openDirectoriesSettings() } override fun onScrolledToEnd() = viewModel.loadNextPage() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt index a1e3bd9b4..20136ac36 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt @@ -1,14 +1,12 @@ package org.koitharu.kotatsu.local.ui -import android.content.Intent import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider import androidx.fragment.app.Fragment import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment -import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity +import org.koitharu.kotatsu.core.nav.router class LocalListMenuProvider( private val fragment: Fragment, @@ -21,7 +19,7 @@ class LocalListMenuProvider( override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) - menu.findItem(R.id.action_filter)?.isVisible = FilterSheetFragment.isSupported(fragment) + menu.findItem(R.id.action_filter)?.isVisible = fragment.router.isFilterSupported() } override fun onMenuItemSelected(menuItem: MenuItem): Boolean { @@ -32,14 +30,12 @@ class LocalListMenuProvider( } R.id.action_directories -> { - fragment.context?.run { - startActivity(Intent(this, MangaDirectoriesActivity::class.java)) - } + fragment.router.openDirectoriesSettings() true } R.id.action_filter -> { - FilterSheetFragment.show(fragment.childFragmentManager) + fragment.router.showFilterSheet() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt index 5c60c1bf9..421a723e3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt @@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.SharedFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.toFileOrNull @@ -25,6 +26,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase import org.koitharu.kotatsu.local.domain.model.LocalManga +import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel import javax.inject.Inject @@ -107,6 +109,12 @@ class LocalListViewModel @Inject constructor( } } + override suspend fun mapMangaList( + destination: MutableCollection, + manga: Collection, + mode: ListMode + ) = mangaListMapper.toListModelList(destination, manga, mode, MangaListMapper.NO_SAVED) + override fun createEmptyState(canResetFilter: Boolean): EmptyState = if (canResetFilter) { super.createEmptyState(true) } else { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt index 4afdb3ecb..126913b7d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt @@ -7,24 +7,20 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.core.widget.TextViewCompat -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.KotatsuColors +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setProgressIcon -import org.koitharu.kotatsu.core.util.ext.showDistinct -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding -import org.koitharu.kotatsu.parsers.model.Manga import com.google.android.material.R as materialR @AndroidEntryPoint @@ -78,7 +74,7 @@ class LocalInfoDialog : AlertDialogFragment(), View.OnCl } else { c.getString( R.string.chapters_deleted_pattern, - c.resources.getQuantityString(R.plurals.chapters, result.first, result.first), + c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first), FileSize.BYTES.format(c, result.second), ) } @@ -108,16 +104,4 @@ class LocalInfoDialog : AlertDialogFragment(), View.OnCl ) view.animateSegments(listOf(segment)) } - - companion object { - - const val ARG_MANGA = "manga" - private const val TAG = "LocalInfoDialog" - - fun show(fm: FragmentManager, manga: Manga) { - LocalInfoDialog().withArgs(1) { - putParcelable(ARG_MANGA, ParcelableManga(manga)) - }.showDistinct(fm, TAG) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt index 648a8383c..1f1c78bd8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt @@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call @@ -25,7 +26,7 @@ class LocalInfoViewModel @Inject constructor( private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, ) : BaseViewModel() { - private val manga = savedStateHandle.require(LocalInfoDialog.ARG_MANGA).manga + private val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga val isCleaningUp = MutableStateFlow(false) val onCleanedUp = MutableEventFlow>() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt index 5ca72a833..d2a1100eb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt @@ -32,17 +32,17 @@ class CoverRestoreInterceptor @Inject constructor( val result = chain.proceed() if (result is ErrorResult && result.throwable.shouldRestore()) { request.extras[mangaKey]?.let { - if (restoreManga(it)) { - return chain.withRequest(request.newBuilder().build()).proceed() + return if (restoreManga(it)) { + chain.withRequest(request.newBuilder().build()).proceed() } else { - return result + result } } request.extras[bookmarkKey]?.let { - if (restoreBookmark(it)) { - return chain.withRequest(request.newBuilder().build()).proceed() + return if (restoreBookmark(it)) { + chain.withRequest(request.newBuilder().build()).proceed() } else { - return result + result } } } @@ -66,7 +66,7 @@ class CoverRestoreInterceptor @Inject constructor( } private suspend fun restoreMangaImpl(manga: Manga): Boolean { - if (dataRepository.findMangaById(manga.id) == null || manga.isLocal) { + if (dataRepository.findMangaById(manga.id, withChapters = false) == null || manga.isLocal) { return false } val repo = repositoryFactory.create(manga.source) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt index 87181e871..3e5a394a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt @@ -14,8 +14,9 @@ import androidx.activity.viewModels import androidx.appcompat.view.ActionMode import androidx.core.app.ActivityCompat import androidx.core.content.ContextCompat -import androidx.core.graphics.Insets +import androidx.core.net.toUri import androidx.core.view.SoftwareKeyboardControllerCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.children import androidx.core.view.inputmethod.EditorInfoCompat import androidx.core.view.isInvisible @@ -40,6 +41,8 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router +import org.koitharu.kotatsu.core.parser.MangaLinkResolver import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.ui.BaseActivity @@ -47,31 +50,26 @@ import org.koitharu.kotatsu.core.ui.util.FadingAppbarMediator import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.util.OptionsMenuBadgeHelper import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView +import org.koitharu.kotatsu.core.util.ext.consume +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf +import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.databinding.ActivityMainBinding import org.koitharu.kotatsu.details.service.MangaPrefetchService -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment import org.koitharu.kotatsu.local.ui.LocalIndexUpdateService import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner -import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.search.ui.MangaListActivity -import org.koitharu.kotatsu.search.ui.multi.SearchActivity +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel -import org.koitharu.kotatsu.settings.SettingsActivity -import org.koitharu.kotatsu.settings.about.AppUpdateActivity import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService import javax.inject.Inject import com.google.android.material.R as materialR @@ -81,8 +79,10 @@ private const val TAG_SEARCH = "search" @AndroidEntryPoint class MainActivity : BaseActivity(), AppBarOwner, BottomNavOwner, View.OnClickListener, - View.OnFocusChangeListener, SearchSuggestionListener, - MainNavigationDelegate.OnFragmentChangedListener, View.OnLayoutChangeListener { + View.OnFocusChangeListener, + SearchSuggestionListener, + MainNavigationDelegate.OnFragmentChangedListener, + View.OnLayoutChangeListener { @Inject lateinit var settings: AppSettings @@ -137,9 +137,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged) viewModel.feedCounter.observe(this, ::onFeedCounterChanged) viewModel.appUpdate.observe(this, MenuInvalidator(this)) - viewModel.onFirstStart.observeEvent(this) { - WelcomeSheet.show(supportFragmentManager) - } + viewModel.onFirstStart.observeEvent(this) { router.showWelcomeSheet() } viewModel.isBottomNavPinned.observe(this, ::setNavbarPinned) searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged) viewBinding.bottomNav?.addOnLayoutChangeListener(this) @@ -148,6 +146,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav override fun onRestoreInstanceState(savedInstanceState: Bundle) { super.onRestoreInstanceState(savedInstanceState) adjustSearchUI(isSearchOpened(), animate = false) + navigationDelegate.syncSelectedItem() } override fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { @@ -188,7 +187,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } R.id.action_settings -> { - startActivity(SettingsActivity.newIntent(this)) + router.openSettings() true } @@ -198,7 +197,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } R.id.action_app_update -> { - startActivity(Intent(this, AppUpdateActivity::class.java)) + router.openAppUpdate() true } @@ -211,12 +210,28 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeMask) + viewBinding.toolbarCard.updateLayoutParams { + marginEnd = barsInsets.end(v) + marginStart = if (viewBinding.navRail != null) { + 0 + } else { + barsInsets.start(v) + } + } + viewBinding.bottomNav?.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, ) - viewBinding.bottomNav?.updatePadding(bottom = insets.bottom) + viewBinding.navRail?.updateLayoutParams { + marginStart = barsInsets.start(v) + topMargin = barsInsets.top + bottomMargin = barsInsets.bottom + } + return insets.consume(v, typeMask, start = viewBinding.navRail != null) } override fun onLayoutChange( @@ -241,7 +256,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav if (fragment == null) { supportFragmentManager.commit { setReorderingAllowed(true) - add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH) + add(R.id.container, SearchSuggestionFragment(), TAG_SEARCH) navigationDelegate.primaryFragment?.let { setMaxLifecycle(it, Lifecycle.State.STARTED) } @@ -253,14 +268,20 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } override fun onMangaClick(manga: Manga) { - startActivity(DetailsActivity.newIntent(this, manga)) + router.openDetails(manga) } - override fun onQueryClick(query: String, submit: Boolean) { + override fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) { viewBinding.searchView.query = query if (submit && query.isNotEmpty()) { - startActivity(SearchActivity.newIntent(this, query)) - searchSuggestionViewModel.saveQuery(query) + if (kind == SearchKind.SIMPLE && MangaLinkResolver.isValidLink(query)) { + router.openDetails(query.toUri()) + } else { + router.openSearch(query, kind) + if (kind != SearchKind.TAG) { + searchSuggestionViewModel.saveQuery(query) + } + } viewBinding.searchView.post { closeSearchCallback.handleOnBackPressed() } @@ -268,7 +289,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } override fun onTagClick(tag: MangaTag) { - startActivity(MangaListActivity.newIntent(this, tag.source, MangaListFilter(tags = setOf(tag)))) + router.openSearch(tag.title, SearchKind.TAG) } override fun onQueryChanged(query: String) { @@ -280,8 +301,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } override fun onSourceClick(source: MangaSource) { - val intent = MangaListActivity.newIntent(this, source, null) - startActivity(intent) + router.openList(source, null, null) } override fun onSupportActionModeStarted(mode: ActionMode) { @@ -302,10 +322,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav private fun onOpenReader(manga: Manga) { val fab = viewBinding.fab ?: viewBinding.navRail?.headerView - val options = fab?.let { - scaleUpActivityOptionsOf(it) - } - startActivity(IntentBuilder(this).manga(manga).build(), options) + router.openReader(manga, fab) } private fun onFeedCounterChanged(counter: Int) { @@ -324,7 +341,8 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav } private fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.fab?.isEnabled = !isLoading + val fab = viewBinding.fab ?: viewBinding.navRail?.headerView ?: return + fab.isEnabled = !isLoading } private fun onResumeEnabledChanged(isEnabled: Boolean) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt index 611774e87..593526f76 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt @@ -26,8 +26,7 @@ import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksFragment import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.NavItem import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner -import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition -import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled +import org.koitharu.kotatsu.core.util.ext.smoothScrollToTop import org.koitharu.kotatsu.explore.ui.ExploreFragment import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment import org.koitharu.kotatsu.history.ui.HistoryListFragment @@ -59,20 +58,16 @@ class MainNavigationDelegate( } override fun onNavigationItemSelected(item: MenuItem): Boolean { - return onNavigationItemSelected(item.itemId) + return if (onNavigationItemSelected(item.itemId)) { + item.isChecked = true + true + } else { + false + } } override fun onNavigationItemReselected(item: MenuItem) { - val fragment = fragmentManager.findFragmentByTag(TAG_PRIMARY) - if (fragment == null || fragment !is RecyclerViewOwner || fragment.view == null) { - return - } - val recyclerView = fragment.recyclerView - if (recyclerView.context.isAnimationsEnabled) { - recyclerView.smoothScrollToPosition(0) - } else { - recyclerView.firstVisibleItemPosition = 0 - } + onNavigationItemReselected() } override fun handleOnBackPressed() { @@ -105,6 +100,15 @@ class MainNavigationDelegate( setCounter(item.id, counter) } + fun syncSelectedItem() { + val fragment = primaryFragment ?: return + onFragmentChanged(fragment, fromUser = false) + val itemId = getItemId(fragment) + if (navBar.selectedItemId != itemId) { + navBar.selectedItemId = itemId + } + } + private fun setCounter(@IdRes id: Int, counter: Int) { if (counter == 0) { navBar.getBadge(id)?.isVisible = false @@ -136,19 +140,22 @@ class MainNavigationDelegate( } private fun onNavigationItemSelected(@IdRes itemId: Int): Boolean { - return setPrimaryFragment( - when (itemId) { - R.id.nav_history -> HistoryListFragment::class.java - R.id.nav_favorites -> FavouritesContainerFragment::class.java - R.id.nav_explore -> ExploreFragment::class.java - R.id.nav_feed -> FeedFragment::class.java - R.id.nav_local -> LocalListFragment::class.java - R.id.nav_suggestions -> SuggestionsFragment::class.java - R.id.nav_bookmarks -> AllBookmarksFragment::class.java - R.id.nav_updated -> UpdatesFragment::class.java - else -> return false - }, - ) + val newFragment = when (itemId) { + R.id.nav_history -> HistoryListFragment::class.java + R.id.nav_favorites -> FavouritesContainerFragment::class.java + R.id.nav_explore -> ExploreFragment::class.java + R.id.nav_feed -> FeedFragment::class.java + R.id.nav_local -> LocalListFragment::class.java + R.id.nav_suggestions -> SuggestionsFragment::class.java + R.id.nav_bookmarks -> AllBookmarksFragment::class.java + R.id.nav_updated -> UpdatesFragment::class.java + else -> return false + } + if (!setPrimaryFragment(newFragment)) { + // probably already selected + onNavigationItemReselected() + } + return true } private fun getItemId(fragment: Fragment) = when (fragment) { @@ -177,6 +184,11 @@ class MainNavigationDelegate( return true } + private fun onNavigationItemReselected() { + val recyclerView = (primaryFragment as? RecyclerViewOwner)?.recyclerView ?: return + recyclerView.smoothScrollToTop() + } + private fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) { isEnabled = getItemId(fragment) != firstItem()?.itemId listeners.forEach { it.onFragmentChanged(fragment, fromUser) } @@ -240,4 +252,9 @@ class MainNavigationDelegate( fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) } + + companion object { + + const val MAX_ITEM_COUNT = 6 + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt index 9516a8447..ea04b96a1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.text.Editable -import android.text.TextWatcher import android.view.KeyEvent import android.view.View import android.view.WindowManager @@ -16,15 +15,18 @@ import androidx.biometric.BiometricManager.Authenticators.BIOMETRIC_WEAK import androidx.biometric.BiometricManager.BIOMETRIC_SUCCESS import androidx.biometric.BiometricPrompt import androidx.biometric.BiometricPrompt.AuthenticationCallback -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import com.google.android.material.textfield.TextInputLayout import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityProtectBinding import com.google.android.material.R as materialR @@ -32,7 +34,7 @@ import com.google.android.material.R as materialR class ProtectActivity : BaseActivity(), TextView.OnEditorActionListener, - TextWatcher, + DefaultTextWatcher, View.OnClickListener { private val viewModel by viewModels() @@ -62,6 +64,18 @@ class ProtectActivity : } } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) + viewBinding.root.setPadding( + barsInsets.left + basePadding, + barsInsets.top + basePadding, + barsInsets.right + basePadding, + barsInsets.bottom + basePadding, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onStart() { super.onStart() canUseBiometric = useFingerprint() @@ -71,16 +85,6 @@ class ProtectActivity : } } - override fun onWindowInsetsChanged(insets: Insets) { - val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) - viewBinding.root.setPadding( - basePadding + insets.left, - basePadding + insets.top, - basePadding + insets.right, - basePadding + insets.bottom, - ) - } - override fun onClick(v: View) { when (v.id) { R.id.button_next -> viewModel.tryUnlock(viewBinding.editPassword.text?.toString().orEmpty()) @@ -98,10 +102,6 @@ class ProtectActivity : } } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - override fun afterTextChanged(s: Editable?) { viewBinding.layoutPassword.error = null viewBinding.buttonNext.isEnabled = !s.isNullOrEmpty() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt index 50bed1ffc..fefed0f7e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt @@ -8,24 +8,25 @@ import android.view.View import android.view.ViewGroup import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone -import androidx.fragment.app.FragmentManager +import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.widgets.ChipsView +import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.databinding.SheetWelcomeBinding import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment import java.util.Locale @AndroidEntryPoint @@ -55,6 +56,14 @@ class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipC viewModel.types.observe(viewLifecycleOwner, ::onTypesChanged) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + viewBinding?.scrollView?.updatePadding( + bottom = insets.getInsets(typeMask).bottom, + ) + return insets.consume(v, typeMask, bottom = true) + } + override fun onChipClick(chip: Chip, data: Any?) { when (data) { is ContentType -> viewModel.setTypeChecked(data, !chip.isChecked) @@ -82,7 +91,7 @@ class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipC override fun onActivityResult(result: Uri?) { if (result != null) { - RestoreDialogFragment.show(parentFragmentManager, result) + router.showBackupRestoreDialog(result) } } @@ -111,17 +120,4 @@ class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipC }, ) } - - companion object { - - private const val TAG = "WelcomeSheet" - - 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 - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt index 7ec601e12..7bbc1b3bb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeViewModel.kt @@ -7,6 +7,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.LocaleComparator import org.koitharu.kotatsu.core.util.ext.mapSortedByCount @@ -25,7 +26,7 @@ import javax.inject.Inject @HiltViewModel class WelcomeViewModel @Inject constructor( private val repository: MangaSourcesRepository, - @ApplicationContext context: Context, + @LocalizedAppContext context: Context, ) : BaseViewModel() { private val allSources = repository.allMangaSources diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/data/TapGridSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/data/TapGridSettings.kt index d3fc3dab1..3d856bbeb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/data/TapGridSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/data/TapGridSettings.kt @@ -3,16 +3,19 @@ package org.koitharu.kotatsu.reader.data import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.flowOn import org.koitharu.kotatsu.core.util.ext.getEnumValue import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.putAll import org.koitharu.kotatsu.core.util.ext.putEnumValue import org.koitharu.kotatsu.reader.domain.TapGridArea import org.koitharu.kotatsu.reader.ui.tapgrid.TapAction import javax.inject.Inject +@Reusable class TapGridSettings @Inject constructor(@ApplicationContext context: Context) { private val prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) @@ -43,6 +46,13 @@ class TapGridSettings @Inject constructor(@ApplicationContext context: Context) fun observe() = prefs.observe().flowOn(Dispatchers.IO) + fun getAllValues(): Map = prefs.all + + fun upsertAll(m: Map) = prefs.edit { + clear() + putAll(m) + } + private fun initPrefs(withDefaultValues: Boolean) { prefs.edit { clear() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt index 5b4f9cb5b..7b6c34de1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/EdgeDetector.kt @@ -6,7 +6,6 @@ import android.graphics.Color import android.graphics.Point import android.graphics.Rect import androidx.annotation.ColorInt -import androidx.collection.LruCache import androidx.core.graphics.alpha import androidx.core.graphics.blue import androidx.core.graphics.get @@ -23,13 +22,14 @@ import kotlinx.coroutines.runInterruptible import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext +import org.koitharu.kotatsu.core.util.SynchronizedSieveCache import org.koitharu.kotatsu.core.util.ext.use import kotlin.math.abs class EdgeDetector(private val context: Context) { private val mutex = Mutex() - private val cache = LruCache(CACHE_SIZE) + private val cache = SynchronizedSieveCache(CACHE_SIZE) suspend fun getBounds(imageSource: ImageSource): Rect? { cache[imageSource]?.let { rect -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index ff5359b3f..9dc2b9535 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -28,6 +28,7 @@ import okhttp3.OkHttpClient import okhttp3.Request import okio.use import org.jetbrains.annotations.Blocking +import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.image.BitmapDecoderCompat import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.network.MangaHttpClient @@ -36,6 +37,7 @@ import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin @@ -47,9 +49,9 @@ import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isNotEmpty import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.isZipUri -import org.koitharu.kotatsu.core.util.ext.mimeType import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.ramAvailable +import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred @@ -57,7 +59,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadSlowdownDispatcher import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.pager.ReaderPage @@ -66,13 +67,12 @@ import java.util.LinkedList import java.util.concurrent.atomic.AtomicInteger import java.util.zip.ZipFile import javax.inject.Inject -import kotlin.concurrent.Volatile import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @ActivityRetainedScoped class PageLoader @Inject constructor( - @ApplicationContext private val context: Context, + @LocalizedAppContext private val context: Context, lifecycle: ActivityRetainedLifecycle, @MangaHttpClient private val okHttp: OkHttpClient, private val cache: PagesCache, @@ -146,7 +146,7 @@ class PageLoader @Inject constructor( val entry = zip.getEntry(uri.fragment) context.ensureRamAtLeast(entry.size * 2) zip.getInputStream(entry).use { - BitmapDecoderCompat.decode(it, entry.mimeType) + BitmapDecoderCompat.decode(it, MimeTypes.getMimeTypeFromExtension(entry.name)) } } } @@ -253,7 +253,7 @@ class PageLoader @Inject constructor( val request = createPageRequest(pageUrl, page.source) imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> response.requireBody().withProgress(progress).use { - cache.put(pageUrl, it.source(), response.mimeType) + cache.put(pageUrl, it.source(), it.contentType()?.toMimeType()) } }.toUri() } @@ -267,7 +267,7 @@ class PageLoader @Inject constructor( private fun Deferred.isValid(): Boolean { return getCompletionResultOrNull()?.map { uri -> uri.exists() && uri.isTargetNotEmpty() - }?.getOrDefault(false) ?: true + }?.getOrDefault(false) != false } private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt index 339c71038..1989d2703 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt @@ -5,9 +5,9 @@ import android.content.Intent import android.os.Build import android.os.Environment import android.provider.DocumentsContract -import android.webkit.MimeTypeMap import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toUri +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.toUriOrNull import java.io.File @@ -15,8 +15,7 @@ class PageSaveContract : ActivityResultContracts.CreateDocument("image/*") { override fun createIntent(context: Context, input: String): Intent { val intent = super.createIntent(context, input.substringAfterLast(File.separatorChar)) - intent.type = MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(input.substringAfterLast('.')) ?: "image/*" + intent.type = MimeTypes.getMimeTypeFromExtension(input)?.toString() ?: "image/*" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val defaultUri = input.toUriOrNull()?.run { path?.let { p -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index a6b1d2d3e..cf0e6aa2e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -1,13 +1,10 @@ package org.koitharu.kotatsu.reader.ui import android.content.Context -import android.graphics.BitmapFactory import android.net.Uri -import android.webkit.MimeTypeMap import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toFile import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile @@ -28,7 +25,11 @@ import okio.buffer import okio.openZip import okio.sink import okio.source +import org.koitharu.kotatsu.core.LocalizedAppContext +import org.koitharu.kotatsu.core.image.BitmapDecoderCompat +import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.toFileNameSafe @@ -46,14 +47,13 @@ import kotlin.coroutines.resume class PageSaveHelper @AssistedInject constructor( @Assisted activityResultCaller: ActivityResultCaller, - @ApplicationContext private val context: Context, + @LocalizedAppContext private val context: Context, private val settings: AppSettings, private val pageLoaderProvider: Provider, ) : ActivityResultCallback { private val savePageRequest = activityResultCaller.registerForActivityResult(PageSaveContract(), this) - private val pickDirectoryRequest = - activityResultCaller.registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this) + private val pickDirectoryRequest = OpenDocumentTreeHelper(activityResultCaller, this) private var continuation: CancellableContinuation? = null @@ -99,10 +99,10 @@ class PageSaveHelper @AssistedInject constructor( val pageUri = pageLoader.loadPage(task.page, force = false) val proposedName = task.getFileBaseName() val ext = getPageExtension(pageUrl, pageUri) - val mime = requireNotNull(MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)) { + val mime = requireNotNull(MimeTypes.getMimeTypeFromExtension("_.$ext")) { "Unknown type of $proposedName" } - val destination = destinationDir.createFile(mime, proposedName.substringBeforeLast('.')) + val destination = destinationDir.createFile(mime.toString(), proposedName) copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file")) result.add(destination.uri) } @@ -119,12 +119,7 @@ class PageSaveHelper @AssistedInject constructor( ) { "Invalid page url: $url" } var extension = name.substringAfterLast('.', "") if (extension.length !in 2..4) { - val mimeType = fileUri.toFileOrNull()?.let { file -> getImageMimeType(file) } - extension = if (mimeType != null) { - MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK - } else { - EXTENSION_FALLBACK - } + extension = fileUri.toFileOrNull()?.let { file -> getImageExtension(file) } ?: EXTENSION_FALLBACK } return extension } @@ -155,8 +150,7 @@ class PageSaveHelper @AssistedInject constructor( if (proposedName == null) { return dir } else { - val ext = proposedName.substringAfterLast('.', "") - val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null + val mime = MimeTypes.getMimeTypeFromExtension(proposedName)?.toString() ?: return null return dir.createFile(mime, proposedName.substringBeforeLast('.')) } } @@ -179,12 +173,8 @@ class PageSaveHelper @AssistedInject constructor( } } - private suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) { - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeFile(file.path, options)?.recycle() - options.outMimeType + private suspend fun getImageExtension(file: File): String? = runInterruptible(Dispatchers.IO) { + MimeTypes.getExtension(BitmapDecoderCompat.probeMimeType(file)) } data class Task( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActionsView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActionsView.kt new file mode 100644 index 000000000..c748eaab2 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActionsView.kt @@ -0,0 +1,239 @@ +package org.koitharu.kotatsu.reader.ui + +import android.content.Context +import android.content.SharedPreferences +import android.database.ContentObserver +import android.provider.Settings +import android.util.AttributeSet +import android.view.Gravity +import android.view.LayoutInflater +import android.view.View +import android.widget.Button +import android.widget.FrameLayout +import android.widget.LinearLayout +import androidx.annotation.AttrRes +import androidx.annotation.StringRes +import androidx.core.view.ViewCompat +import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import com.google.android.material.slider.Slider +import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ReaderControl +import org.koitharu.kotatsu.core.util.ext.isRtl +import org.koitharu.kotatsu.core.util.ext.setValueRounded +import org.koitharu.kotatsu.databinding.LayoutReaderActionsBinding +import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES +import org.koitharu.kotatsu.reader.ui.ReaderControlDelegate.OnInteractionListener +import javax.inject.Inject +import com.google.android.material.R as materialR + +@AndroidEntryPoint +class ReaderActionsView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + @AttrRes defStyleAttr: Int = 0, +) : LinearLayout(context, attrs, defStyleAttr), + View.OnClickListener, + SharedPreferences.OnSharedPreferenceChangeListener, + Slider.OnChangeListener, + Slider.OnSliderTouchListener { + + @Inject + lateinit var settings: AppSettings + + private val binding = LayoutReaderActionsBinding.inflate(LayoutInflater.from(context), this) + private val rotationObserver = object : ContentObserver(handler) { + override fun onChange(selfChange: Boolean) { + updateRotationButton() + } + } + private var isSliderChanged = false + private var isSliderTracking = false + + var isSliderEnabled: Boolean + get() = binding.slider.isEnabled + set(value) { + binding.slider.isEnabled = value + binding.slider.setThumbVisible(value) + } + + var isNextEnabled: Boolean + get() = binding.buttonNext.isEnabled + set(value) { + binding.buttonNext.isEnabled = value + } + + var isPrevEnabled: Boolean + get() = binding.buttonPrev.isEnabled + set(value) { + binding.buttonPrev.isEnabled = value + } + + var listener: OnInteractionListener? = null + + init { + orientation = HORIZONTAL + gravity = Gravity.CENTER_VERTICAL + binding.buttonNext.initAction() + binding.buttonPrev.initAction() + binding.buttonSave.initAction() + binding.buttonOptions.initAction() + binding.buttonScreenRotation.initAction() + binding.buttonPagesThumbs.initAction() + binding.slider.setLabelFormatter(PageLabelFormatter()) + binding.slider.addOnChangeListener(this) + binding.slider.addOnSliderTouchListener(this) + updateControlsVisibility() + updatePagesSheetButton() + updateRotationButton() + } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + settings.subscribe(this) + context.contentResolver.registerContentObserver( + Settings.System.CONTENT_URI, true, rotationObserver, + ) + } + + override fun onDetachedFromWindow() { + settings.unsubscribe(this) + context.contentResolver.unregisterContentObserver(rotationObserver) + super.onDetachedFromWindow() + } + + override fun onClick(v: View) { + when (v.id) { + R.id.button_prev -> listener?.switchChapterBy(-1) + R.id.button_next -> listener?.switchChapterBy(1) + R.id.button_save -> listener?.onSavePageClick() + R.id.button_pages_thumbs -> AppRouter.from(this)?.showChapterPagesSheet() + R.id.button_screen_rotation -> listener?.toggleScreenOrientation() + R.id.button_options -> listener?.openMenu() + } + } + + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { + if (fromUser) { + if (isSliderTracking) { + isSliderChanged = true + } else { + listener?.switchPageTo(value.toInt()) + } + } + } + + override fun onStartTrackingTouch(slider: Slider) { + isSliderChanged = false + isSliderTracking = true + } + + override fun onStopTrackingTouch(slider: Slider) { + isSliderTracking = false + if (isSliderChanged) { + listener?.switchPageTo(slider.value.toInt()) + } + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_READER_CONTROLS -> updateControlsVisibility() + AppSettings.KEY_PAGES_TAB, + AppSettings.KEY_DETAILS_TAB, + AppSettings.KEY_DETAILS_LAST_TAB -> updatePagesSheetButton() + } + } + + fun setSliderValue(value: Int, max: Int) { + binding.slider.valueTo = max.toFloat() + binding.slider.setValueRounded(value.toFloat()) + } + + fun setSliderReversed(reversed: Boolean) { + binding.slider.isRtl = reversed != isRtl + } + + private fun updateControlsVisibility() { + val controls = settings.readerControls + binding.buttonPrev.isVisible = ReaderControl.PREV_CHAPTER in controls + binding.buttonNext.isVisible = ReaderControl.NEXT_CHAPTER in controls + binding.buttonPagesThumbs.isVisible = ReaderControl.PAGES_SHEET in controls + binding.buttonScreenRotation.isVisible = ReaderControl.SCREEN_ROTATION in controls + binding.buttonSave.isVisible = ReaderControl.SAVE_PAGE in controls + binding.slider.isVisible = ReaderControl.SLIDER in controls + adjustLayoutParams() + } + + private fun updatePagesSheetButton() { + val isPagesMode = settings.defaultDetailsTab == TAB_PAGES + val button = binding.buttonPagesThumbs + button.setIconResource( + if (isPagesMode) R.drawable.ic_grid else R.drawable.ic_list, + ) + button.setTitle( + if (isPagesMode) R.string.pages else R.string.chapters, + ) + } + + private fun adjustLayoutParams() { + val isSliderVisible = binding.slider.isVisible + repeat(childCount) { i -> + val child = getChildAt(i) + if (child is FrameLayout) { + child.updateLayoutParams { + width = if (isSliderVisible) LayoutParams.WRAP_CONTENT else 0 + weight = if (isSliderVisible) 0f else 1f + } + } + } + } + + private fun updateRotationButton() { + val button = binding.buttonScreenRotation + when { + !button.isVisible -> return + isAutoRotationEnabled() -> { + button.setTitle(R.string.lock_screen_rotation) + button.setIconResource(R.drawable.ic_screen_rotation_lock) + } + + else -> { + button.setTitle(R.string.rotate_screen) + button.setIconResource(R.drawable.ic_screen_rotation) + } + } + } + + private fun Button.initAction() { + setOnClickListener(this@ReaderActionsView) + ViewCompat.setTooltipText(this, contentDescription) + } + + private fun Button.setTitle(@StringRes titleResId: Int) { + val text = resources.getString(titleResId) + contentDescription = text + ViewCompat.setTooltipText(this, text) + } + + private fun isAutoRotationEnabled(): Boolean = Settings.System.getInt( + context.contentResolver, + Settings.System.ACCELEROMETER_ROTATION, + 0, + ) == 1 + + private fun Slider.setThumbVisible(visible: Boolean) { + thumbWidth = if (visible) { + resources.getDimensionPixelSize(materialR.dimen.m3_comp_slider_active_handle_width) + } else { + 0 + } + thumbHeight = if (visible) { + resources.getDimensionPixelSize(materialR.dimen.m3_comp_slider_active_handle_height) + } else { + 0 + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 4adbad36a..798b5ca02 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -1,22 +1,15 @@ package org.koitharu.kotatsu.reader.ui -import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.transition.Fade -import android.transition.Slide -import android.transition.TransitionManager -import android.transition.TransitionSet import android.view.Gravity import android.view.KeyEvent import android.view.MotionEvent import android.view.View -import android.view.ViewGroup.MarginLayoutParams +import android.view.ViewGroup import android.view.WindowManager import androidx.activity.viewModels import androidx.core.graphics.Insets -import androidx.core.view.OnApplyWindowInsetsListener import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible @@ -24,37 +17,34 @@ import androidx.core.view.updateLayoutParams import androidx.core.view.updatePadding import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import androidx.transition.Fade +import androidx.transition.Slide +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.ui.BaseFullscreenActivity import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.util.IdlingDetector -import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.hasGlobalPoint import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled -import org.koitharu.kotatsu.core.util.ext.isRtl import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.postDelayed -import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.zipWithPrevious import org.koitharu.kotatsu.databinding.ActivityReaderBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.pager.pages.PagesSavedObserver -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.reader.data.TapGridSettings import org.koitharu.kotatsu.reader.domain.TapGridArea @@ -71,7 +61,6 @@ class ReaderActivity : TapGridDispatcher.OnGridTouchListener, ReaderConfigSheet.Callback, ReaderControlDelegate.OnInteractionListener, - OnApplyWindowInsetsListener, ReaderNavigationCallback, IdlingDetector.Callback, ZoomControl.ZoomControlListener { @@ -115,18 +104,16 @@ class ReaderActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityReaderBinding.inflate(layoutInflater)) - screenOrientationHelper.init(settings.readerScreenOrientation) readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUp(true, false) touchHelper = TapGridDispatcher(this, this) scrollTimer = scrollTimerFactory.create(this, this) pageSaveHelper = pageSaveHelperFactory.create(this) controlDelegate = ReaderControlDelegate(resources, settings, tapGridSettings, this) - viewBinding.slider.setLabelFormatter(PageLabelFormatter()) viewBinding.zoomControl.listener = this - ReaderSliderListener(viewModel, this).attachToSlider(viewBinding.slider) - insetsDelegate.interceptingWindowInsetsListener = this + viewBinding.actionsView.listener = this idlingDetector.bindToLifecycle(this) + screenOrientationHelper.applySettings() viewModel.onError.observeEvent( this, @@ -151,24 +138,26 @@ class ReaderActivity : onLoadingStateChanged(viewModel.isLoading.value) } viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn) + viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it } viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged) viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this)) - viewModel.isPagesSheetEnabled.observe(this, MenuInvalidator(viewBinding.toolbarBottom)) viewModel.onShowToast.observeEvent(this) { msgId -> Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT) - .setAnchorView(viewBinding.appbarBottom) + .setAnchorView(viewBinding.toolbarDocked) .show() } + viewModel.readerSettings.observe(this) { + viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this)) + } viewModel.isZoomControlsEnabled.observe(this) { viewBinding.zoomControl.isVisible = it } - addMenuProvider(ReaderTopMenuProvider(this, viewModel)) - viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel)) + addMenuProvider(ReaderMenuProvider(viewModel)) } override fun getParentActivityIntent(): Intent? { val manga = viewModel.getMangaOrNull() ?: return null - return DetailsActivity.newIntent(this, manga) + return AppRouter.detailsIntent(this, manga) } override fun onUserInteraction() { @@ -206,7 +195,7 @@ class ReaderActivity : if (viewBinding.appbarTop.isVisible) { lifecycle.postDelayed(TimeUnit.SECONDS.toMillis(1), hideUiRunnable) } - viewBinding.slider.isRtl = mode == ReaderMode.REVERSED + viewBinding.actionsView.setSliderReversed(mode == ReaderMode.REVERSED) } private fun onLoadingStateChanged(isLoading: Boolean) { @@ -217,7 +206,6 @@ class ReaderActivity : } else { viewBinding.toastView.hide() } - viewBinding.toolbarBottom.invalidateMenu() invalidateOptionsMenu() } @@ -238,12 +226,12 @@ class ReaderActivity : rawX >= viewBinding.root.width - gestureInsets.right || rawY >= viewBinding.root.height - gestureInsets.bottom || viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) || - viewBinding.appbarBottom?.hasGlobalPoint(rawX, rawY) == true + viewBinding.toolbarDocked?.hasGlobalPoint(rawX, rawY) == true ) { false } else { val touchables = window.peekDecorView()?.touchables - touchables?.none { it.hasGlobalPoint(rawX, rawY) } ?: true + touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false } } @@ -254,7 +242,7 @@ class ReaderActivity : } override fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { - return controlDelegate.onKeyDown(keyCode) || super.onKeyDown(keyCode, event) + return controlDelegate.onKeyDown(keyCode, event) || super.onKeyDown(keyCode, event) } override fun onKeyUp(keyCode: Int, event: KeyEvent?): Boolean { @@ -305,14 +293,14 @@ class ReaderActivity : .setOrdering(TransitionSet.ORDERING_TOGETHER) .addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop)) .addTransition(Fade().addTarget(viewBinding.infoBar)) - viewBinding.appbarBottom?.let { bottomBar -> - transition.addTransition(Slide(Gravity.BOTTOM).addTarget(bottomBar)) + viewBinding.toolbarDocked?.let { + transition.addTransition(Slide(Gravity.BOTTOM).addTarget(it)) } TransitionManager.beginDelayedTransition(viewBinding.root, transition) } val isFullscreen = settings.isReaderFullscreenEnabled viewBinding.appbarTop.isVisible = isUiVisible - viewBinding.appbarBottom?.isVisible = isUiVisible + viewBinding.toolbarDocked?.isVisible = isUiVisible viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value) viewBinding.infoBar.isTimeVisible = isFullscreen systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen) @@ -327,10 +315,12 @@ class ReaderActivity : right = systemBars.right, left = systemBars.left, ) - viewBinding.appbarBottom?.updateLayoutParams { - bottomMargin = systemBars.bottom + topMargin - rightMargin = systemBars.right + topMargin - leftMargin = systemBars.left + topMargin + if (viewBinding.toolbarDocked != null) { + viewBinding.actionsView.updateLayoutParams { + bottomMargin = systemBars.bottom + rightMargin = systemBars.right + leftMargin = systemBars.left + } } viewBinding.infoBar.updatePadding( top = systemBars.top, @@ -340,8 +330,6 @@ class ReaderActivity : .build() } - override fun onWindowInsetsChanged(insets: Insets) = Unit - override fun switchPageBy(delta: Int) { readerManager.currentReader?.switchPageBy(delta) } @@ -353,11 +341,11 @@ class ReaderActivity : override fun openMenu() { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) val currentMode = readerManager.currentMode ?: return - ReaderConfigSheet.show(supportFragmentManager, currentMode) + router.showReaderConfigSheet(currentMode) } override fun scrollBy(delta: Int, smooth: Boolean): Boolean { - return readerManager.currentReader?.scrollBy(delta, smooth) ?: false + return readerManager.currentReader?.scrollBy(delta, smooth) == true } override fun toggleUiVisibility() { @@ -373,6 +361,28 @@ class ReaderActivity : viewModel.saveCurrentPage(pageSaveHelper) } + override fun toggleScreenOrientation() { + if (screenOrientationHelper.toggleScreenOrientation()) { + Snackbar.make( + viewBinding.container, + if (screenOrientationHelper.isLocked) { + R.string.screen_rotation_locked + } else { + R.string.screen_rotation_unlocked + }, + Snackbar.LENGTH_SHORT, + ).setAnchorView(viewBinding.toolbarDocked) + .show() + } + } + + override fun switchPageTo(index: Int) { + val pages = viewModel.getCurrentChapterPages() + val page = pages?.getOrNull(index) ?: return + val chapterId = viewModel.getCurrentState()?.chapterId ?: return + onPageSelected(ReaderPage(page, index, chapterId)) + } + private fun onReaderBarChanged(isBarEnabled: Boolean) { viewBinding.infoBar.isVisible = isBarEnabled && viewBinding.appbarTop.isGone } @@ -383,71 +393,33 @@ class ReaderActivity : viewBinding.infoBar.update(uiState) if (uiState == null) { supportActionBar?.subtitle = null - viewBinding.slider.isVisible = false + viewBinding.actionsView.setSliderValue(0, 1) + viewBinding.actionsView.isSliderEnabled = false return } + val chapterTitle = uiState.getChapterTitle(resources) supportActionBar?.subtitle = when { uiState.incognito -> getString(R.string.incognito_mode) - else -> uiState.chapterName + else -> chapterTitle } - if (previous?.chapterName != null && uiState.chapterName != previous.chapterName) { - if (!uiState.chapterName.isNullOrEmpty()) { - viewBinding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION) - } + if (chapterTitle != previous?.getChapterTitle(resources) && chapterTitle.isNotEmpty()) { + viewBinding.toastView.showTemporary(chapterTitle, TOAST_DURATION) } if (uiState.isSliderAvailable()) { - viewBinding.slider.valueTo = uiState.totalPages.toFloat() - 1 - viewBinding.slider.setValueRounded(uiState.currentPage.toFloat()) - viewBinding.slider.isVisible = true + viewBinding.actionsView.setSliderValue( + value = uiState.currentPage, + max = uiState.totalPages - 1, + ) } else { - viewBinding.slider.isVisible = false - } - } - - class IntentBuilder(context: Context) { - - private val intent = Intent(context, ReaderActivity::class.java) - .setAction(ACTION_MANGA_READ) - - fun manga(manga: Manga) = apply { - intent.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) - } - - fun mangaId(mangaId: Long) = apply { - intent.putExtra(MangaIntent.KEY_ID, mangaId) - } - - fun incognito(incognito: Boolean) = apply { - intent.putExtra(EXTRA_INCOGNITO, incognito) - } - - fun branch(branch: String?) = apply { - intent.putExtra(EXTRA_BRANCH, branch) - } - - fun state(state: ReaderState?) = apply { - intent.putExtra(EXTRA_STATE, state) + viewBinding.actionsView.setSliderValue(0, 1) } - - fun bookmark(bookmark: Bookmark) = manga( - bookmark.manga, - ).state( - ReaderState( - chapterId = bookmark.chapterId, - page = bookmark.page, - scroll = bookmark.scroll, - ), - ) - - fun build() = intent + viewBinding.actionsView.isSliderEnabled = uiState.isSliderAvailable() + viewBinding.actionsView.isNextEnabled = uiState.hasNextChapter() + viewBinding.actionsView.isPrevEnabled = uiState.hasPreviousChapter() } companion object { - const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA" - const val EXTRA_STATE = "state" - const val EXTRA_BRANCH = "branch" - const val EXTRA_INCOGNITO = "incognito" - private const val TOAST_DURATION = 1500L + private const val TOAST_DURATION = 2000L } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderBottomMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderBottomMenuProvider.kt deleted file mode 100644 index ab1013374..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderBottomMenuProvider.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.core.view.MenuProvider -import androidx.fragment.app.FragmentActivity -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet -import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet - -class ReaderBottomMenuProvider( - private val activity: FragmentActivity, - private val readerManager: ReaderManager, - private val viewModel: ReaderViewModel, -) : MenuProvider { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_reader_bottom, menu) - onPrepareMenu(menu) // fix, not called in toolbar - } - - override fun onPrepareMenu(menu: Menu) { - val hasPages = viewModel.content.value.pages.isNotEmpty() - menu.findItem(R.id.action_pages_thumbs).run { - isVisible = hasPages - if (hasPages) { - setIcon(if (viewModel.isPagesSheetEnabled.value) R.drawable.ic_grid else R.drawable.ic_list) - } - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_pages_thumbs -> { - ChaptersPagesSheet.show(activity.supportFragmentManager) - true - } - - R.id.action_options -> { - viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) - val currentMode = readerManager.currentMode ?: return false - ReaderConfigSheet.show(activity.supportFragmentManager, currentMode) - true - } - - else -> false - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt index 53421d319..4827a7a40 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt @@ -2,22 +2,31 @@ package org.koitharu.kotatsu.reader.ui import android.content.res.Resources import android.view.KeyEvent +import android.view.View import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.reader.data.TapGridSettings import org.koitharu.kotatsu.reader.domain.TapGridArea import org.koitharu.kotatsu.reader.ui.tapgrid.TapAction +import kotlin.math.sign class ReaderControlDelegate( resources: Resources, private val settings: AppSettings, private val tapGridSettings: TapGridSettings, private val listener: OnInteractionListener, -) { +) : View.OnClickListener { private var minScrollDelta = resources.getDimensionPixelSize(R.dimen.reader_scroll_delta_min) + override fun onClick(v: View) { + when (v.id) { + R.id.button_prev -> listener.switchChapterBy(-1) + R.id.button_next -> listener.switchChapterBy(1) + } + } + fun onGridTouch(area: TapGridArea): Boolean { val action = tapGridSettings.getTapAction( area = area, @@ -35,77 +44,48 @@ class ReaderControlDelegate( processAction(action) } - fun onKeyDown(keyCode: Int): Boolean = when (keyCode) { + fun onKeyDown(keyCode: Int, event: KeyEvent?): Boolean { + when (keyCode) { + KeyEvent.KEYCODE_NAVIGATE_NEXT, + KeyEvent.KEYCODE_SPACE -> switchBy(1, event, false) - KeyEvent.KEYCODE_R -> { - listener.switchPageBy(1) - true - } + KeyEvent.KEYCODE_PAGE_DOWN -> switchBy(1, event, false) - KeyEvent.KEYCODE_L -> { - listener.switchPageBy(-1) - true - } - KeyEvent.KEYCODE_VOLUME_UP -> if (settings.isReaderVolumeButtonsEnabled) { - listener.switchPageBy(-1) - true - } else { - false - } + KeyEvent.KEYCODE_NAVIGATE_PREVIOUS -> switchBy(-1, event, false) + KeyEvent.KEYCODE_PAGE_UP -> switchBy(-1, event, false) - KeyEvent.KEYCODE_VOLUME_DOWN -> if (settings.isReaderVolumeButtonsEnabled) { - listener.switchPageBy(1) - true - } else { - false - } + KeyEvent.KEYCODE_R -> switchBy(1, null, false) - KeyEvent.KEYCODE_SPACE, - KeyEvent.KEYCODE_PAGE_DOWN, - -> { - listener.switchPageBy(1) - true - } + KeyEvent.KEYCODE_L -> switchBy(-1, null, false) - KeyEvent.KEYCODE_DPAD_RIGHT -> { - listener.switchPageBy(if (isReaderTapsReversed()) -1 else 1) - true - } + KeyEvent.KEYCODE_VOLUME_UP -> if (settings.isReaderVolumeButtonsEnabled) { + switchBy(-1, event, false) + } else { + return false + } - KeyEvent.KEYCODE_PAGE_UP, - -> { - listener.switchPageBy(-1) - true - } + KeyEvent.KEYCODE_VOLUME_DOWN -> if (settings.isReaderVolumeButtonsEnabled) { + switchBy(1, event, false) + } else { + return false + } - KeyEvent.KEYCODE_DPAD_LEFT -> { - listener.switchPageBy(if (isReaderTapsReversed()) 1 else -1) - true - } + KeyEvent.KEYCODE_DPAD_RIGHT -> switchByRelative(-1, event) - KeyEvent.KEYCODE_DPAD_CENTER -> { - listener.toggleUiVisibility() - true - } + KeyEvent.KEYCODE_DPAD_LEFT -> switchByRelative(1, event) - KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, - KeyEvent.KEYCODE_DPAD_UP -> { - if (!listener.scrollBy(-minScrollDelta, smooth = true)) { - listener.switchPageBy(-1) - } - true - } + KeyEvent.KEYCODE_DPAD_CENTER -> listener.toggleUiVisibility() - KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, - KeyEvent.KEYCODE_DPAD_DOWN -> { - if (!listener.scrollBy(minScrollDelta, smooth = true)) { - listener.switchPageBy(1) - } - true - } + KeyEvent.KEYCODE_SYSTEM_NAVIGATION_UP, + KeyEvent.KEYCODE_DPAD_UP -> switchBy(-1, event, true) + + KeyEvent.KEYCODE_SYSTEM_NAVIGATION_DOWN, + KeyEvent.KEYCODE_DPAD_DOWN -> switchBy(1, event, true) - else -> false + else -> return false + } + return true } fun onKeyUp(keyCode: Int, @Suppress("UNUSED_PARAMETER") event: KeyEvent?): Boolean { @@ -128,12 +108,30 @@ class ReaderControlDelegate( return settings.isReaderControlAlwaysLTR && listener.readerMode == ReaderMode.REVERSED } + private fun switchBy(delta: Int, event: KeyEvent?, scroll: Boolean) { + if (event?.isCtrlPressed == true) { + listener.switchChapterBy(delta) + } else if (scroll) { + if (!listener.scrollBy(minScrollDelta * delta.sign, smooth = true)) { + listener.switchPageBy(delta) + } + } else { + listener.switchPageBy(delta) + } + } + + private fun switchByRelative(delta: Int, event: KeyEvent?) { + return switchBy(if (isReaderTapsReversed()) -delta else delta, event, scroll = false) + } + interface OnInteractionListener { val readerMode: ReaderMode? fun switchPageBy(delta: Int) + fun switchPageTo(index: Int) + fun switchChapterBy(delta: Int) fun scrollBy(delta: Int, smooth: Boolean): Boolean @@ -142,6 +140,10 @@ class ReaderControlDelegate( fun openMenu() + fun onSavePageClick() + + fun toggleScreenOrientation() + fun isReaderResumed(): Boolean } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt index 34bca6de8..f723c7fe9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint @@ -24,7 +25,8 @@ import androidx.core.graphics.withScale import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList +import org.koitharu.kotatsu.core.util.ext.isNightMode import org.koitharu.kotatsu.core.util.ext.measureDimension import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.util.format @@ -34,6 +36,8 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import com.google.android.material.R as materialR +private const val ALPHA_TEXT = 200 +private const val ALPHA_BG = 180 class ReaderInfoBarView @JvmOverloads constructor( context: Context, @@ -41,7 +45,7 @@ class ReaderInfoBarView @JvmOverloads constructor( @AttrRes defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr) { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) private val textBounds = Rect() private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private val systemStateReceiver = SystemStateReceiver() @@ -52,16 +56,17 @@ class ReaderInfoBarView @JvmOverloads constructor( private val insetRightFallback: Int private val insetTopFallback: Int private val insetCornerFallback = getSystemUiDimensionOffset("rounded_corner_content_padding") - private val colorText = ColorUtils.setAlphaComponent( - context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK), - 200, - ) - private val colorOutline = ColorUtils.setAlphaComponent( - context.getThemeColor(materialR.attr.colorSurface, Color.WHITE), - 200, - ) + private var colorText = + (context.getThemeColorStateList(materialR.attr.colorOnSurface) + ?: ColorStateList.valueOf(Color.BLACK)).withAlpha(ALPHA_TEXT) + private var colorBackground = + (context.getThemeColorStateList(materialR.attr.colorSurface) + ?: ColorStateList.valueOf(Color.WHITE)).withAlpha(ALPHA_BG) private val batteryIcon = ContextCompat.getDrawable(context, R.drawable.ic_battery_outline) + private var currentTextColor: Int = Color.TRANSPARENT + private var currentBackgroundColor: Int = Color.TRANSPARENT + private var currentOutlineColor: Int = Color.TRANSPARENT private var timeText = timeFormat.format(LocalTime.now()) private var batteryText = "" private var text: String = "" @@ -73,6 +78,12 @@ class ReaderInfoBarView @JvmOverloads constructor( private val innerWidth get() = width - paddingLeft - paddingRight - insetLeft - insetRight + var drawBackground: Boolean = false + set(value) { + field = value + invalidate() + } + var isTimeVisible: Boolean = true set(value) { field = value @@ -90,6 +101,7 @@ class ReaderInfoBarView @JvmOverloads constructor( insetLeftFallback = if (isRtl) insetEnd else insetStart insetRightFallback = if (isRtl) insetStart else insetEnd insetTopFallback = minOf(insetLeftFallback, insetRightFallback) + batteryIcon?.setTintList(colorText) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -106,24 +118,37 @@ class ReaderInfoBarView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) + if (drawBackground) { + canvas.drawColor(currentBackgroundColor) + } computeTextHeight() val h = innerHeight.toFloat() val ty = h / 2f + textBounds.height() / 2f - textBounds.bottom paint.textAlign = Paint.Align.LEFT - canvas.drawTextOutline( - text, - (paddingLeft + insetLeft).toFloat(), - paddingTop + insetTop + ty, - ) + paint.color = currentTextColor + paint.style = Paint.Style.FILL + if (drawBackground) { + canvas.drawText(text, (paddingLeft + insetLeft).toFloat(), paddingTop + insetTop + ty, paint) + } else { + canvas.drawTextOutline(text, (paddingLeft + insetLeft).toFloat(), paddingTop + insetTop + ty) + } if (isTimeVisible) { paint.textAlign = Paint.Align.RIGHT var endX = (width - paddingRight - insetRight).toFloat() - canvas.drawTextOutline(timeText, endX, paddingTop + insetTop + ty) + if (drawBackground) { + canvas.drawText(timeText, endX, paddingTop + insetTop + ty, paint) + } else { + canvas.drawTextOutline(timeText, endX, paddingTop + insetTop + ty) + } if (batteryText.isNotEmpty()) { paint.getTextBounds(timeText, 0, timeText.length, textBounds) endX -= textBounds.width() endX -= h * 0.6f - canvas.drawTextOutline(batteryText, endX, paddingTop + insetTop + ty) + if (drawBackground) { + canvas.drawText(batteryText, endX, paddingTop + insetTop + ty, paint) + } else { + canvas.drawTextOutline(batteryText, endX, paddingTop + insetTop + ty) + } batteryIcon?.let { paint.getTextBounds(batteryText, 0, batteryText.length, textBounds) endX -= textBounds.width() @@ -134,7 +159,11 @@ class ReaderInfoBarView @JvmOverloads constructor( endX.toInt(), (iconCenter + h / 2).toInt(), ) - it.drawWithOutline(canvas) + if (drawBackground) { + it.draw(canvas) + } else { + it.drawWithOutline(canvas) + } } } } @@ -169,6 +198,43 @@ class ReaderInfoBarView @JvmOverloads constructor( context.unregisterReceiver(systemStateReceiver) } + override fun verifyDrawable(who: Drawable): Boolean { + return who == batteryIcon || super.verifyDrawable(who) + } + + override fun jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState() + batteryIcon?.jumpToCurrentState() + } + + override fun onCreateDrawableState(extraSpace: Int): IntArray? { + val iconState = batteryIcon?.state ?: return super.onCreateDrawableState(extraSpace) + return mergeDrawableStates(super.onCreateDrawableState(extraSpace + iconState.size), iconState) + } + + override fun drawableStateChanged() { + currentTextColor = colorText.getColorForState(drawableState, colorText.defaultColor) + currentBackgroundColor = colorBackground.getColorForState(drawableState, colorBackground.defaultColor) + currentOutlineColor = ColorUtils.setAlphaComponent(currentBackgroundColor, Color.alpha(currentTextColor)) + super.drawableStateChanged() + if (batteryIcon != null && batteryIcon.isStateful && batteryIcon.setState(drawableState)) { + invalidateDrawable(batteryIcon) + } + } + + fun applyColorScheme(isBlackOnWhite: Boolean) { + val isDarkTheme = resources.isNightMode + colorText = (context.getThemeColorStateList( + if (isBlackOnWhite != isDarkTheme) materialR.attr.colorOnSurface else materialR.attr.colorOnSurfaceInverse, + ) ?: ColorStateList.valueOf(if (isBlackOnWhite) Color.BLACK else Color.WHITE)).withAlpha(ALPHA_TEXT) + colorBackground = (context.getThemeColorStateList( + if (isBlackOnWhite != isDarkTheme) materialR.attr.colorSurface else materialR.attr.colorSurfaceInverse, + ) ?: ColorStateList.valueOf(if (isBlackOnWhite) Color.WHITE else Color.BLACK)).withAlpha(ALPHA_BG) + batteryIcon?.setTintList(colorText) + drawableStateChanged() + } + + @SuppressLint("StringFormatMatches") fun update(state: ReaderUiState?) { text = if (state != null) { context.getString( @@ -199,32 +265,6 @@ class ReaderInfoBarView @JvmOverloads constructor( return textBounds.height() } - private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) { - paint.color = colorOutline - paint.style = Paint.Style.STROKE - drawText(text, x, y, paint) - paint.color = colorText - paint.style = Paint.Style.FILL - drawText(text, x, y, paint) - } - - private fun Drawable.drawWithOutline(canvas: Canvas) { - if (bounds.isEmpty) { - return - } - var requiredScale = (bounds.width() + paint.strokeWidth * 2f) / bounds.width().toFloat() - setTint(colorOutline) - canvas.withScale(requiredScale, requiredScale, bounds.exactCenterX(), bounds.exactCenterY()) { - draw(canvas) - } - requiredScale = 1f / requiredScale - canvas.withScale(requiredScale, requiredScale, bounds.exactCenterX(), bounds.exactCenterY()) { - draw(canvas) - } - setTint(colorText) - draw(canvas) - } - private fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) { insetLeft = insetLeftFallback insetRight = insetRightFallback @@ -253,6 +293,32 @@ class ReaderInfoBarView @JvmOverloads constructor( } } + private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) { + paint.color = currentOutlineColor + paint.style = Paint.Style.STROKE + drawText(text, x, y, paint) + paint.color = currentTextColor + paint.style = Paint.Style.FILL + drawText(text, x, y, paint) + } + + private fun Drawable.drawWithOutline(canvas: Canvas) { + if (bounds.isEmpty) { + return + } + var requiredScale = (bounds.width() + paint.strokeWidth * 2f) / bounds.width().toFloat() + setTint(currentOutlineColor) + canvas.withScale(requiredScale, requiredScale, bounds.exactCenterX(), bounds.exactCenterY()) { + draw(canvas) + } + requiredScale = 1f / requiredScale + canvas.withScale(requiredScale, requiredScale, bounds.exactCenterX(), bounds.exactCenterY()) { + draw(canvas) + } + setTint(currentTextColor) + draw(canvas) + } + private inner class SystemStateReceiver : BroadcastReceiver() { override fun onReceive(context: Context, intent: Intent) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderTopMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuProvider.kt similarity index 86% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderTopMenuProvider.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuProvider.kt index e2fa85e0a..04a3b4522 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderTopMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuProvider.kt @@ -4,16 +4,14 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider -import androidx.fragment.app.FragmentActivity import org.koitharu.kotatsu.R -class ReaderTopMenuProvider( - private val activity: FragmentActivity, +class ReaderMenuProvider( private val viewModel: ReaderViewModel, ) : MenuProvider { override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_reader_top, menu) + menuInflater.inflate(R.menu.opt_reader, menu) } override fun onPrepareMenu(menu: Menu) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt deleted file mode 100644 index 5bfc74953..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderSliderListener.kt +++ /dev/null @@ -1,47 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -import com.google.android.material.slider.Slider -import org.koitharu.kotatsu.reader.ui.pager.ReaderPage - -class ReaderSliderListener( - private val viewModel: ReaderViewModel, - private val callback: ReaderNavigationCallback, -) : Slider.OnChangeListener, Slider.OnSliderTouchListener { - - private var isChanged = false - private var isTracking = false - - override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { - if (fromUser) { - if (isTracking) { - isChanged = true - } else { - switchPageToIndex(value.toInt()) - } - } - } - - override fun onStartTrackingTouch(slider: Slider) { - isChanged = false - isTracking = true - } - - override fun onStopTrackingTouch(slider: Slider) { - isTracking = false - if (isChanged) { - switchPageToIndex(slider.value.toInt()) - } - } - - fun attachToSlider(slider: Slider) { - slider.addOnChangeListener(this) - slider.addOnSliderTouchListener(this) - } - - private fun switchPageToIndex(index: Int) { - val pages = viewModel.getCurrentChapterPages() - val page = pages?.getOrNull(index) ?: return - val chapterId = viewModel.getCurrentState()?.chapterId ?: return - callback.onPageSelected(ReaderPage(page, index, chapterId)) - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt index a2acc8df7..6357c20f7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt @@ -1,16 +1,19 @@ package org.koitharu.kotatsu.reader.ui +import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.content.Context import android.util.AttributeSet -import android.view.Gravity -import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator import androidx.annotation.StringRes +import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.transition.Fade -import androidx.transition.Slide -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet import com.google.android.material.textview.MaterialTextView +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getAnimationDuration +import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled class ReaderToastView @JvmOverloads constructor( context: Context, @@ -18,6 +21,8 @@ class ReaderToastView @JvmOverloads constructor( defStyleAttr: Int = 0, ) : MaterialTextView(context, attrs, defStyleAttr) { + private var currentAnimator: ViewPropertyAnimator? = null + private var hideRunnable = Runnable { hide() } @@ -25,8 +30,7 @@ class ReaderToastView @JvmOverloads constructor( fun show(message: CharSequence) { removeCallbacks(hideRunnable) text = message - setupTransition() - isVisible = true + showImpl() } fun show(@StringRes messageId: Int) { @@ -40,8 +44,7 @@ class ReaderToastView @JvmOverloads constructor( fun hide() { removeCallbacks(hideRunnable) - setupTransition() - isVisible = false + hideImpl() } override fun onDetachedFromWindow() { @@ -49,13 +52,41 @@ class ReaderToastView @JvmOverloads constructor( super.onDetachedFromWindow() } - private fun setupTransition() { - val parentView = parent as? ViewGroup ?: return - val transition = TransitionSet() - .setOrdering(TransitionSet.ORDERING_TOGETHER) - .addTarget(this) - .addTransition(Slide(Gravity.BOTTOM)) - .addTransition(Fade()) - TransitionManager.beginDelayedTransition(parentView, transition) + private fun showImpl() { + currentAnimator?.cancel() + clearAnimation() + if (!context.isAnimationsEnabled) { + currentAnimator = null + isVisible = true + return + } + alpha = 0f + isVisible = true + currentAnimator = animate() + .alpha(1f) + .setInterpolator(DecelerateInterpolator()) + .setDuration(context.getAnimationDuration(R.integer.config_shorterAnimTime)) + .setListener(null) + } + + private fun hideImpl() { + currentAnimator?.cancel() + clearAnimation() + if (!context.isAnimationsEnabled) { + currentAnimator = null + isGone = true + return + } + currentAnimator = animate() + .alpha(0f) + .setInterpolator(AccelerateInterpolator()) + .setDuration(context.getAnimationDuration(R.integer.config_shorterAnimTime)) + .setListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + isGone = true + } + }, + ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index dd08b07d6..2610754db 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -15,15 +15,12 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharedFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filterNotNull import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus @@ -31,9 +28,10 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.model.getPreferredBranch +import org.koitharu.kotatsu.core.nav.MangaIntent +import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.observeAsFlow @@ -44,7 +42,6 @@ import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.details.data.MangaDetails import org.koitharu.kotatsu.details.domain.DetailsInteractor import org.koitharu.kotatsu.details.domain.DetailsLoadUseCase -import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet.Companion.TAB_PAGES import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.history.data.HistoryRepository @@ -104,8 +101,8 @@ class ReaderViewModel @Inject constructor( private var stateChangeJob: Job? = null init { - selectedBranch.value = savedStateHandle.get(ReaderActivity.EXTRA_BRANCH) - readingState.value = savedStateHandle[ReaderActivity.EXTRA_STATE] + selectedBranch.value = savedStateHandle.get(ReaderIntent.EXTRA_BRANCH) + readingState.value = savedStateHandle[ReaderIntent.EXTRA_STATE] mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, false) } } @@ -114,15 +111,13 @@ class ReaderViewModel @Inject constructor( val onShowToast = MutableEventFlow() val uiState = MutableStateFlow(null) - val incognitoMode = if (savedStateHandle.get(ReaderActivity.EXTRA_INCOGNITO) == true) { + val incognitoMode = if (savedStateHandle.get(ReaderIntent.EXTRA_INCOGNITO) == true) { MutableStateFlow(true) } else { interactor.observeIncognitoMode(manga) .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, false) } - val isPagesSheetEnabled = observeIsPagesSheetEnabled() - val content = MutableStateFlow(ReaderContent(emptyList(), null)) val pageAnimation = settings.observeAsStateFlow( @@ -137,6 +132,12 @@ class ReaderViewModel @Inject constructor( valueProducer = { isReaderBarEnabled }, ) + val isInfoBarTransparent = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_READER_BAR_TRANSPARENT, + valueProducer = { isReaderBarTransparent }, + ) + val isKeepScreenOnEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_READER_SCREEN_ON, @@ -192,10 +193,6 @@ class ReaderViewModel @Inject constructor( init { loadImpl() - settings.observe() - .onEach { key -> - if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged() - }.launchIn(viewModelScope + Dispatchers.Default) launchJob(Dispatchers.Default) { val mangaId = manga.filterNotNull().first().id appShortcutManager.notifyMangaOpened(mangaId) @@ -230,7 +227,7 @@ class ReaderViewModel @Inject constructor( fun saveCurrentState(state: ReaderState? = null) { if (state != null) { readingState.value = state - savedStateHandle[ReaderActivity.EXTRA_STATE] = state + savedStateHandle[ReaderIntent.EXTRA_STATE] = state } if (incognitoMode.value) { return @@ -387,7 +384,7 @@ class ReaderViewModel @Inject constructor( private fun loadImpl() { loadingJob = launchLoadingJob(Dispatchers.Default) { - val details = detailsLoadUseCase.invoke(intent).first { x -> x.isLoaded } + val details = detailsLoadUseCase.invoke(intent, force = false).first { x -> x.isLoaded } mangaDetails.value = details chaptersLoader.init(details) val manga = details.toManga() @@ -442,13 +439,11 @@ class ReaderViewModel @Inject constructor( val chapterIndex = m.chapters[chapter.branch]?.indexOfFirst { it.id == chapter.id } ?: -1 val newState = ReaderUiState( mangaName = m.toManga().title, - branch = chapter.branch, - chapterName = chapter.name, - chapterNumber = chapterIndex + 1, + chapter = chapter, + chapterIndex = chapterIndex, chaptersTotal = m.chapters[chapter.branch].sizeOrZero(), totalPages = chaptersLoader.getPagesCount(chapter.id), currentPage = state.page, - isSliderEnabled = settings.isReaderSliderEnabled, percent = computePercent(state.chapterId, state.page), incognito = incognitoMode.value, ) @@ -487,11 +482,6 @@ class ReaderViewModel @Inject constructor( valueProducer = { isReaderZoomButtonsEnabled }, ) - private fun observeIsPagesSheetEnabled() = settings.observe() - .filter { it == AppSettings.KEY_PAGES_TAB || it == AppSettings.KEY_DETAILS_TAB || it == AppSettings.KEY_DETAILS_LAST_TAB } - .map { settings.defaultDetailsTab == TAB_PAGES } - .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, settings.defaultDetailsTab == TAB_PAGES) - private suspend fun getStateFromIntent(manga: Manga): ReaderState { val history = historyRepository.getOne(manga) val preselectedBranch = selectedBranch.value diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt index 546577b5a..620671d8b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt @@ -13,10 +13,14 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart +import org.koitharu.kotatsu.core.prefs.AppSettings import javax.inject.Inject @ActivityScoped -class ScreenOrientationHelper @Inject constructor(private val activity: Activity) { +class ScreenOrientationHelper @Inject constructor( + private val activity: Activity, + private val settings: AppSettings, +) { val isAutoRotationEnabled: Boolean get() = Settings.System.getInt( @@ -45,10 +49,10 @@ class ScreenOrientationHelper @Inject constructor(private val activity: Activity } } - fun init(orientation: Int) { + fun applySettings() { if (activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { // https://developer.android.com/reference/android/R.attr.html#screenOrientation - activity.requestedOrientation = orientation + activity.requestedOrientation = settings.readerScreenOrientation } } @@ -68,4 +72,13 @@ class ScreenOrientationHelper @Inject constructor(private val activity: Activity emit(isAutoRotationEnabled) }.distinctUntilChanged() .conflate() + + fun toggleScreenOrientation(): Boolean = if (isAutoRotationEnabled) { + val newValue = !isLocked + isLocked = newValue + true + } else { + isLandscape = !isLandscape + false + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt index b2e132758..ad45d14bb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt @@ -1,17 +1,12 @@ package org.koitharu.kotatsu.reader.ui.colorfilter -import android.content.Context -import android.content.Intent import android.content.res.Resources import android.graphics.Bitmap import android.os.Bundle import android.view.View -import android.view.ViewGroup import android.widget.CompoundButton import androidx.activity.viewModels -import androidx.core.graphics.Insets -import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import coil3.ImageLoader import coil3.request.ImageRequest import coil3.request.bitmapConfig @@ -23,9 +18,8 @@ import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.Slider import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.indicator @@ -34,14 +28,13 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.core.util.ext.setValueRounded +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityColorFilterBinding -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.nullIfEmpty import org.koitharu.kotatsu.reader.domain.ReaderColorFilter import javax.inject.Inject -import com.google.android.material.R as materialR @AndroidEntryPoint class ColorFilterConfigActivity : @@ -57,10 +50,7 @@ class ColorFilterConfigActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityColorFilterBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } + setDisplayHomeAsUp(true, true) viewBinding.sliderBrightness.addOnChangeListener(this) viewBinding.sliderContrast.addOnChangeListener(this) val formatter = PercentLabelFormatter(resources) @@ -81,6 +71,20 @@ class ColorFilterConfigActivity : loadPreview(viewModel.preview) } + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.root.setPadding( + barsInsets.left, + barsInsets.top, + barsInsets.right, + barsInsets.bottom, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onValueChange(slider: Slider, value: Float, fromUser: Boolean) { if (fromUser) { when (slider.id) { @@ -104,19 +108,6 @@ class ColorFilterConfigActivity : } } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.scrollView.updatePadding( - bottom = insets.bottom, - ) - viewBinding.toolbar.updateLayoutParams { - topMargin = insets.top - } - } - fun showSaveConfirmation() { MaterialAlertDialogBuilder(this) .setTitle(R.string.apply) @@ -132,8 +123,8 @@ class ColorFilterConfigActivity : private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) { viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f) viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f) - viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false) - viewBinding.switchGrayscale.setChecked(readerColorFilter?.isGrayscale ?: false, false) + viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted == true, false) + viewBinding.switchGrayscale.setChecked(readerColorFilter?.isGrayscale == true, false) viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() } @@ -169,15 +160,4 @@ class ColorFilterConfigActivity : return pattern.format(percent) } } - - companion object { - - const val EXTRA_PAGES = "pages" - const val EXTRA_MANGA = "manga_id" - - fun newIntent(context: Context, manga: Manga, page: MangaPage) = - Intent(context, ColorFilterConfigActivity::class.java) - .putExtra(EXTRA_MANGA, ParcelableManga(manga)) - .putExtra(EXTRA_PAGES, ParcelableMangaPage(page)) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt index 91cc435a6..dec4a3d13 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -13,7 +14,6 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.reader.domain.ReaderColorFilter -import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA import javax.inject.Inject @HiltViewModel @@ -23,12 +23,12 @@ class ColorFilterConfigViewModel @Inject constructor( private val mangaDataRepository: MangaDataRepository, ) : BaseViewModel() { - private val manga = savedStateHandle.require(EXTRA_MANGA).manga + private val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga private var initialColorFilter: ReaderColorFilter? = null val colorFilter = MutableStateFlow(null) val onDismiss = MutableEventFlow() - val preview = savedStateHandle.require(ColorFilterConfigActivity.EXTRA_PAGES).page + val preview = savedStateHandle.require(AppRouter.KEY_PAGES).page val isChanged: Boolean get() = colorFilter.value != initialColorFilter diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt index 37da96afe..3669539e5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt @@ -5,9 +5,10 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.CompoundButton +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.fragment.app.FragmentManager +import androidx.core.view.updatePadding import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButtonToggleGroup @@ -19,22 +20,21 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ScreenOrientationHelper -import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity -import org.koitharu.kotatsu.settings.SettingsActivity import javax.inject.Inject @AndroidEntryPoint @@ -64,7 +64,7 @@ class ReaderConfigSheet : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - mode = arguments?.getInt(ARG_MODE) + mode = arguments?.getInt(AppRouter.KEY_READER_MODE) ?.let { ReaderMode.valueOf(it) } ?: ReaderMode.STANDARD imageServerDelegate = ImageServerDelegate( @@ -126,10 +126,18 @@ class ReaderConfigSheet : } } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + viewBinding?.scrollView?.updatePadding( + bottom = insets.getInsets(typeMask).bottom, + ) + return insets.consume(v, typeMask, bottom = true) + } + override fun onClick(v: View) { when (v.id) { R.id.button_settings -> { - startActivity(SettingsActivity.newReaderSettingsIntent(v.context)) + router.openReaderSettings() dismissAllowingStateLoss() } @@ -145,7 +153,7 @@ class ReaderConfigSheet : R.id.button_color_filter -> { val page = viewModel.getCurrentPage() ?: return val manga = viewModel.getMangaOrNull() ?: return - startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page)) + router.openColorFilterConfig(manga, page) } R.id.button_image_server -> viewLifecycleScope.launch { @@ -243,14 +251,4 @@ class ReaderConfigSheet : fun onSavePageClick() } - - companion object { - - private const val TAG = "ReaderConfigBottomSheet" - private const val ARG_MODE = "mode" - - fun show(fm: FragmentManager, mode: ReaderMode) = ReaderConfigSheet().withArgs(1) { - putInt(ARG_MODE, mode.id) - }.showDistinct(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt index f93822540..03ea3677d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ReaderBackground import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.reader.domain.ReaderColorFilter @@ -34,6 +35,9 @@ class ReaderSettings( val zoomMode: ZoomMode get() = settings.zoomMode + val background: ReaderBackground + get() = settings.readerBackground + val colorFilter: ReaderColorFilter? get() = colorFilterFlow.value?.takeUnless { it.isEmpty } ?: settings.readerColorFilter @@ -51,8 +55,7 @@ class ReaderSettings( get() = settings.isPagesNumbersEnabled fun applyBackground(view: View) { - val bg = settings.readerBackground - view.background = bg.resolve(view.context) + view.background = background.resolve(view.context) } fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt index c4ac87358..57fe76149 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/BaseReaderFragment.kt @@ -1,7 +1,8 @@ package org.koitharu.kotatsu.reader.ui.pager import android.os.Bundle -import androidx.core.graphics.Insets +import android.view.View +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.activityViewModels import androidx.viewbinding.ViewBinding import org.koitharu.kotatsu.core.prefs.ReaderAnimation @@ -32,6 +33,8 @@ abstract class BaseReaderFragment : BaseFragment(), ZoomCont } } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat = insets + override fun onPause() { super.onPause() viewModel.saveCurrentState(getCurrentState()) @@ -51,8 +54,6 @@ abstract class BaseReaderFragment : BaseFragment(), ZoomCont return context?.isAnimationsEnabled == true && viewModel.pageAnimation.value != ReaderAnimation.NONE } - override fun onWindowInsetsChanged(insets: Insets) = Unit - abstract fun switchPageBy(delta: Int) abstract fun switchPageTo(position: Int, smooth: Boolean) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index d37907fc8..9a5fb85e4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -80,7 +80,7 @@ class PageHolderDelegate( fun showErrorDetails(url: String?) { val e = error ?: return - exceptionResolver.showDetails(e, url) + exceptionResolver.showErrorDetails(e, url) } fun onAttachedToWindow() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt index f4de7734b..b62236cbf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt @@ -1,19 +1,28 @@ package org.koitharu.kotatsu.reader.ui.pager +import android.content.res.Resources +import org.koitharu.kotatsu.core.model.getLocalizedTitle +import org.koitharu.kotatsu.parsers.model.MangaChapter + data class ReaderUiState( val mangaName: String?, - val branch: String?, - val chapterName: String?, - val chapterNumber: Int, + val chapter: MangaChapter, + val chapterIndex: Int, val chaptersTotal: Int, val currentPage: Int, val totalPages: Int, val percent: Float, val incognito: Boolean, - private val isSliderEnabled: Boolean, ) { - fun isSliderAvailable(): Boolean { - return isSliderEnabled && totalPages > 1 && currentPage < totalPages - } + val chapterNumber: Int + get() = chapterIndex + 1 + + fun hasNextChapter(): Boolean = chapterNumber < chaptersTotal + + fun hasPreviousChapter(): Boolean = chapterIndex > 0 + + fun isSliderAvailable(): Boolean = totalPages > 1 && currentPage < totalPages + + fun getChapterTitle(resources: Resources) = chapter.getLocalizedTitle(resources, chapterIndex) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt index f7b106afb..756e81717 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/webtoon/WebtoonImageView.kt @@ -132,7 +132,7 @@ class WebtoonImageView @JvmOverloads constructor( 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 + textAlign = Paint.Align.LEFT textSize = context.resources.resolveDp(14f) debugPaint = this } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 0caec63bb..382e0035b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -12,8 +12,8 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.drop import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.util.ext.addMenuProvider @@ -22,12 +22,10 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.filter.ui.FilterCoordinator -import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.settings.SettingsActivity +import org.koitharu.kotatsu.search.domain.SearchKind @AndroidEntryPoint class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { @@ -42,9 +40,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { addMenuProvider(RemoteListMenuProvider()) addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel)) viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) - viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { - startActivity(DetailsActivity.newIntent(binding.root.context, it)) - } + viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) } filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() } .drop(1) .observe(viewLifecycleOwner) { @@ -66,7 +62,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { } override fun onFilterClick(view: View?) { - FilterSheetFragment.show(getChildFragmentManager()) + router.showFilterSheet() } override fun onEmptyActionClick() { @@ -77,6 +73,15 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { } } + override fun onFooterButtonClick() { + val filter = filterCoordinator.snapshot().listFilter + when { + !filter.query.isNullOrEmpty() -> router.openSearch(filter.query.orEmpty(), SearchKind.SIMPLE) + !filter.author.isNullOrEmpty() -> router.openSearch(filter.author.orEmpty(), SearchKind.AUTHOR) + filter.tags.size == 1 -> router.openSearch(filter.tags.singleOrNull()?.title.orEmpty(), SearchKind.TAG) + } + } + override fun onSecondaryErrorActionClick(error: Throwable) { openInBrowser(error.getCauseUrl()) } @@ -86,13 +91,10 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) .show() } else { - startActivity( - BrowserActivity.newIntent( - requireContext(), - url, - viewModel.source, - viewModel.source.getTitle(requireContext()), - ), + router.openBrowser( + url = url, + source = viewModel.source, + title = viewModel.source.getTitle(requireContext()), ) } } @@ -105,7 +107,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_source_settings -> { - startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), viewModel.source)) + router.openSourceSettings(viewModel.source) true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 55f08c9b4..958c5fe4d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.getCauseUrl @@ -32,6 +33,7 @@ import org.koitharu.kotatsu.explore.domain.ExploreRepository import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.list.domain.MangaListMapper import org.koitharu.kotatsu.list.ui.MangaListViewModel +import org.koitharu.kotatsu.list.ui.model.ButtonFooter import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter @@ -50,7 +52,7 @@ open class RemoteListViewModel @Inject constructor( mangaRepositoryFactory: MangaRepository.Factory, final override val filterCoordinator: FilterCoordinator, settings: AppSettings, - mangaListMapper: MangaListMapper, + protected val mangaListMapper: MangaListMapper, downloadScheduler: DownloadWorker.Scheduler, private val exploreRepository: ExploreRepository, sourcesRepository: MangaSourcesRepository, @@ -85,10 +87,11 @@ open class RemoteListViewModel @Inject constructor( list == null -> add(LoadingState) list.isEmpty() -> add(createEmptyState(canResetFilter = filterCoordinator.isFilterApplied)) else -> { - mangaListMapper.toListModelList(this, list, mode) + mapMangaList(this, list, mode) when { error != null -> add(error.toErrorFooter()) hasNext -> add(LoadingFooter()) + else -> getFooter()?.let(::add) } } } @@ -171,6 +174,24 @@ open class RemoteListViewModel @Inject constructor( protected open suspend fun onBuildList(list: MutableList) = Unit + protected open suspend fun mapMangaList( + destination: MutableCollection, + manga: Collection, + mode: ListMode + ) = mangaListMapper.toListModelList(destination, manga, mode) + + protected open fun getFooter(): ButtonFooter? { + val filter = filterCoordinator.snapshot().listFilter + val hasQuery = !filter.query.isNullOrEmpty() + val hasAuthor = !filter.author.isNullOrEmpty() + val isOneTag = filter.tags.size == 1 + return if ((hasQuery xor isOneTag xor hasAuthor) && !(hasQuery && isOneTag && hasAuthor)) { + ButtonFooter(R.string.global_search) + } else { + null + } + } + fun openRandom() { if (randomJob?.isActive == true) { return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt index a10a912f4..647828d32 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt @@ -1,11 +1,10 @@ package org.koitharu.kotatsu.scrobbling.common.ui.config -import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View import androidx.activity.viewModels -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import coil3.ImageLoader import coil3.request.error @@ -15,18 +14,19 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.disposeImageRequest import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.showOrHide +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter @@ -46,7 +46,7 @@ class ScrobblerConfigActivity : BaseActivity(), super.onCreate(savedInstanceState) setContentView(ActivityScrobblerConfigBinding.inflate(layoutInflater)) setTitle(viewModel.titleResId) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUp(true, false) val listAdapter = ScrobblingMangaAdapter(this, coil, this) with(viewBinding.recyclerView) { @@ -74,19 +74,25 @@ class ScrobblerConfigActivity : BaseActivity(), processIntent(intent) } - override fun onWindowInsetsChanged(insets: Insets) { - val rv = viewBinding.recyclerView - rv.updatePadding( - left = insets.left + rv.paddingTop, - right = insets.right + rv.paddingTop, - bottom = insets.bottom + rv.paddingTop, + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + val basePadding = v.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) + viewBinding.appbar.updatePadding( + top = barsInsets.top, + left = barsInsets.left, + right = barsInsets.right, ) + viewBinding.recyclerView.setPadding( + barsInsets.left + basePadding, + barsInsets.top + basePadding, + barsInsets.right + basePadding, + barsInsets.bottom + basePadding, + ) + return insets.consumeAllSystemBarsInsets() } override fun onItemClick(item: ScrobblingInfo, view: View) { - startActivity( - DetailsActivity.newIntent(this, item.mangaId), - ) + router.openDetails(item.mangaId) } override fun onClick(v: View) { @@ -133,16 +139,9 @@ class ScrobblerConfigActivity : BaseActivity(), } companion object { - - const val EXTRA_SERVICE_ID = "service" - const val HOST_SHIKIMORI_AUTH = "shikimori-auth" const val HOST_ANILIST_AUTH = "anilist-auth" const val HOST_MAL_AUTH = "mal-auth" const val HOST_KITSU_AUTH = "kitsu-auth" - - fun newIntent(context: Context, service: ScrobblerService) = - Intent(context, ScrobblerConfigActivity::class.java) - .putExtra(EXTRA_SERVICE_ID, service.id) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt index 1bf51ec23..ca5f614cc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call @@ -100,11 +100,11 @@ class ScrobblerConfigViewModel @Inject constructor( private fun getScrobblerService( savedStateHandle: SavedStateHandle, ): ScrobblerService { - val serviceId = savedStateHandle.get(ScrobblerConfigActivity.EXTRA_SERVICE_ID) ?: 0 + val serviceId = savedStateHandle.get(AppRouter.KEY_ID) ?: 0 if (serviceId != 0) { return ScrobblerService.entries.first { it.id == serviceId } } - val uri = savedStateHandle.require(BaseActivity.EXTRA_DATA) + val uri = savedStateHandle.require(AppRouter.KEY_DATA) return when (uri.host) { ScrobblerConfigActivity.HOST_SHIKIMORI_AUTH -> ScrobblerService.SHIKIMORI ScrobblerConfigActivity.HOST_ANILIST_AUTH -> ScrobblerService.ANILIST diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt index f3334a338..7118e9504 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt @@ -7,7 +7,8 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.widget.SearchView -import androidx.fragment.app.FragmentManager +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView.NO_ID @@ -17,13 +18,13 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.util.CollapseActionViewCallback import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback +import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.firstVisibleItemPosition import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.observe @@ -31,15 +32,12 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setProgressIcon import org.koitharu.kotatsu.core.util.ext.setTabsEnabled import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerMangaSelectionDecoration import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerSelectorAdapter import javax.inject.Inject @@ -119,6 +117,15 @@ class ScrobblingSelectorSheet : paginationScrollListener = null } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + val basePadding = v.resources.getDimensionPixelOffset(R.dimen.list_spacing_normal) + viewBinding?.recyclerView?.updatePadding( + bottom = basePadding + insets.getInsets(typeMask).bottom, + ) + return insets.consume(v, typeMask, bottom = true) + } + override fun onCurrentListChanged(previousList: MutableList, currentList: MutableList) { if (previousList.singleOrNull() is LoadingFooter) { val rv = viewBinding?.recyclerView ?: return @@ -229,7 +236,7 @@ class ScrobblingSelectorSheet : private fun initTabs() { val entries = viewModel.availableScrobblers val tabs = requireViewBinding().tabs - val selectedId = arguments?.getInt(ARG_SCROBBLER, -1) ?: -1 + val selectedId = arguments?.getInt(AppRouter.KEY_ID, -1) ?: -1 tabs.removeAllTabs() tabs.clearOnTabSelectedListeners() tabs.addOnTabSelectedListener(this) @@ -244,18 +251,4 @@ class ScrobblingSelectorSheet : } } } - - companion object { - - private const val TAG = "ScrobblingSelectorBottomSheet" - private const val ARG_SCROBBLER = "scrobbler" - - fun show(fm: FragmentManager, manga: Manga, scrobblerService: ScrobblerService?) = - ScrobblingSelectorSheet().withArgs(2) { - putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga)) - if (scrobblerService != null) { - putInt(ARG_SCROBBLER, scrobblerService.id) - } - }.show(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index d6c016482..c15974eda 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call @@ -23,6 +23,7 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.core.util.ext.requireValue import org.koitharu.kotatsu.history.data.HistoryRepository +import org.koitharu.kotatsu.list.domain.ReadingProgress import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState @@ -41,7 +42,7 @@ class ScrobblingSelectorViewModel @Inject constructor( private val historyRepository: HistoryRepository, ) : BaseViewModel() { - val manga = savedStateHandle.require(MangaIntent.KEY_MANGA).manga + val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga val availableScrobblers = scrobblers.filter { it.isEnabled } @@ -159,7 +160,7 @@ class ScrobblingSelectorViewModel @Inject constructor( rating = prevInfo?.rating ?: 0f, status = prevInfo?.status ?: when { history == null -> ScrobblingStatus.PLANNED - history.percent == 1f -> ScrobblingStatus.COMPLETED + ReadingProgress.isCompleted(history.percent) -> ScrobblingStatus.COMPLETED else -> ScrobblingStatus.READING }, comment = prevInfo?.comment, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt index a63a733c9..4acc0bdcf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt @@ -1,18 +1,21 @@ package org.koitharu.kotatsu.scrobbling.kitsu.ui +import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.text.Editable -import android.text.TextWatcher import android.view.View -import androidx.core.graphics.Insets import androidx.core.net.toUri +import androidx.core.view.WindowInsetsCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityKitsuAuthBinding import org.koitharu.kotatsu.parsers.util.urlEncoded -class KitsuAuthActivity : BaseActivity(), View.OnClickListener, TextWatcher { +class KitsuAuthActivity : BaseActivity(), View.OnClickListener, DefaultTextWatcher { private val regexEmail = Regex("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", RegexOption.IGNORE_CASE) @@ -25,14 +28,19 @@ class KitsuAuthActivity : BaseActivity(), View.OnClick viewBinding.editPassword.addTextChangedListener(this) } - override fun onWindowInsetsChanged(insets: Insets) { + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) viewBinding.root.setPadding( - basePadding + insets.left, - basePadding + insets.top, - basePadding + insets.right, - basePadding + insets.bottom, + barsInsets.left + basePadding, + barsInsets.top + basePadding, + barsInsets.right + basePadding, + barsInsets.bottom + basePadding, ) + return insets.consumeAllSystemBarsInsets() } override fun onClick(v: View) { @@ -42,10 +50,6 @@ class KitsuAuthActivity : BaseActivity(), View.OnClick } } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - override fun afterTextChanged(s: Editable?) { val email = viewBinding.editEmail.text?.toString()?.trim() val password = viewBinding.editPassword.text?.toString()?.trim() @@ -55,6 +59,7 @@ class KitsuAuthActivity : BaseActivity(), View.OnClick && password.length >= 3 } + @SuppressLint("UnsafeImplicitIntentLaunch") private fun continueAuth() { val email = viewBinding.editEmail.text?.toString()?.trim().orEmpty() val password = viewBinding.editPassword.text?.toString()?.trim().orEmpty() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchKind.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchKind.kt new file mode 100644 index 000000000..33dfcd189 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchKind.kt @@ -0,0 +1,6 @@ +package org.koitharu.kotatsu.search.domain + +enum class SearchKind { + + SIMPLE, TITLE, AUTHOR, TAG +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchResults.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchResults.kt new file mode 100644 index 000000000..b4ebed44c --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchResults.kt @@ -0,0 +1,11 @@ +package org.koitharu.kotatsu.search.domain + +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.SortOrder + +data class SearchResults( + val listFilter: MangaListFilter, + val sortOrder: SortOrder, + val manga: List, +) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt new file mode 100644 index 000000000..b12dfc8eb --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/domain/SearchV2Helper.kt @@ -0,0 +1,140 @@ +package org.koitharu.kotatsu.search.domain + +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.parsers.model.Manga +import org.koitharu.kotatsu.parsers.model.MangaListFilter +import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.util.almostEquals +import org.koitharu.kotatsu.parsers.util.levenshteinDistance +import org.koitharu.kotatsu.parsers.util.runCatchingCancellable + +private const val MATCH_THRESHOLD_DEFAULT = 0.2f + +class SearchV2Helper @AssistedInject constructor( + @Assisted private val source: MangaSource, + private val mangaRepositoryFactory: MangaRepository.Factory, + private val dataRepository: MangaDataRepository, + private val settings: AppSettings, +) { + + suspend operator fun invoke(query: String, kind: SearchKind): SearchResults? { + if (settings.isNsfwContentDisabled && source.isNsfw()) { + return null + } + val repository = mangaRepositoryFactory.create(source) + val listFilter = repository.getFilter(query, kind) ?: return null + val sortOrder = repository.getSortOrder(kind) + val list = repository.getList(0, sortOrder, listFilter) + if (list.isEmpty()) { + return null + } + val result = list.toMutableList() + result.postFilter(query, kind) + result.sortByRelevance(query, kind) + return SearchResults(listFilter = listFilter, sortOrder = sortOrder, manga = result) + } + + private suspend fun MangaRepository.getFilter(query: String, kind: SearchKind): MangaListFilter? = when (kind) { + SearchKind.SIMPLE, + SearchKind.TITLE -> if (filterCapabilities.isSearchSupported) { + MangaListFilter(query = query) + } else { + null + } + + SearchKind.AUTHOR -> if (filterCapabilities.isAuthorSearchSupported) { + MangaListFilter(author = query) + } else if (filterCapabilities.isSearchSupported) { + MangaListFilter(query = query) + } else { + null + } + + SearchKind.TAG -> { + val tags = this@SearchV2Helper.dataRepository.findTags(this.source) + runCatchingCancellable { + this@getFilter.getFilterOptions().availableTags + }.onFailure { e -> + e.printStackTraceDebug() + }.getOrDefault(emptySet()) + val tag = tags.find { x -> x.title.equals(query, ignoreCase = true) } + if (tag != null) { + MangaListFilter(tags = setOf(tag)) + } else { + null + } + } + } + + private fun MutableList.postFilter(query: String, kind: SearchKind) { + if (settings.isNsfwContentDisabled) { + removeAll { it.isNsfw } + } + when (kind) { + SearchKind.TITLE -> retainAll { m -> + m.matches(query, MATCH_THRESHOLD_DEFAULT) + } + + SearchKind.AUTHOR -> retainAll { m -> + m.author.isNullOrEmpty() || m.author.equals(query, ignoreCase = true) + } + + SearchKind.SIMPLE, // no filtering expected + SearchKind.TAG -> Unit + } + } + + private fun MutableList.sortByRelevance(query: String, kind: SearchKind) { + when (kind) { + SearchKind.SIMPLE, + SearchKind.TITLE -> sortBy { m -> + minOf(m.title.levenshteinDistance(query), m.altTitle?.levenshteinDistance(query) ?: Int.MAX_VALUE) + } + + SearchKind.AUTHOR -> sortByDescending { m -> + m.author?.equals(query, ignoreCase = true) == true + } + + SearchKind.TAG -> sortByDescending { m -> + m.tags.any { tag -> tag.title.equals(query, ignoreCase = true) } + } + } + } + + private fun MangaRepository.getSortOrder(kind: SearchKind): SortOrder { + val preferred: SortOrder = when (kind) { + SearchKind.SIMPLE, + SearchKind.TITLE, + SearchKind.AUTHOR -> SortOrder.RELEVANCE + + SearchKind.TAG -> SortOrder.POPULARITY + } + return if (preferred in sortOrders) { + preferred + } else { + defaultSortOrder + } + } + + + private fun Manga.matches(query: String, threshold: Float): Boolean { + return matchesTitles(title, query, threshold) || matchesTitles(altTitle, query, threshold) + } + + private fun matchesTitles(a: String?, b: String?, threshold: Float): Boolean { + return !a.isNullOrEmpty() && !b.isNullOrEmpty() && a.almostEquals(b, threshold) + } + + @AssistedFactory + interface Factory { + + fun create(source: MangaSource): SearchV2Helper + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index 989828470..00c100ca0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -1,15 +1,13 @@ package org.koitharu.kotatsu.search.ui -import android.content.Context -import android.content.Intent import android.os.Bundle import android.view.View -import android.view.ViewGroup.MarginLayoutParams -import androidx.core.graphics.Insets +import android.view.ViewGroup import androidx.core.graphics.drawable.toDrawable import androidx.core.os.bundleOf +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding +import androidx.core.view.updatePaddingRelative import androidx.fragment.app.Fragment import androidx.fragment.app.commit import com.google.android.material.appbar.AppBarLayout @@ -19,7 +17,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.MangaSource @@ -28,14 +25,19 @@ import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.util.ViewBadge +import org.koitharu.kotatsu.core.util.ext.consumeSystemBarsInsets +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat +import org.koitharu.kotatsu.core.util.ext.getSerializableExtraCompat import org.koitharu.kotatsu.core.util.ext.getThemeColor import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setTextAndVisible +import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.databinding.ActivityMangaListBinding import org.koitharu.kotatsu.filter.ui.FilterCoordinator import org.koitharu.kotatsu.filter.ui.FilterHeaderFragment @@ -46,7 +48,7 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.isNullOrEmpty +import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import kotlin.math.absoluteValue import com.google.android.material.R as materialR @@ -54,7 +56,9 @@ import com.google.android.material.R as materialR @AndroidEntryPoint class MangaListActivity : BaseActivity(), - AppBarOwner, View.OnClickListener, FilterCoordinator.Owner, AppBarLayout.OnOffsetChangedListener { + AppBarOwner, View.OnClickListener, + FilterCoordinator.Owner, + AppBarLayout.OnOffsetChangedListener { override val appBar: AppBarLayout get() = viewBinding.appbar @@ -69,30 +73,20 @@ class MangaListActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityMangaListBinding.inflate(layoutInflater)) - val filter = intent.getParcelableExtraCompat(EXTRA_FILTER)?.filter - source = MangaSource(intent.getStringExtra(EXTRA_SOURCE)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + val filter = intent.getParcelableExtraCompat(AppRouter.KEY_FILTER)?.filter + val sortOrder = intent.getSerializableExtraCompat(AppRouter.KEY_SORT_ORDER) + source = MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)) + setDisplayHomeAsUp(true, false) if (viewBinding.containerFilterHeader != null) { viewBinding.appbar.addOnOffsetChangedListener(this) } viewBinding.buttonOrder?.setOnClickListener(this) title = source.getTitle(this) - initList(source, filter) + initList(source, filter, sortOrder) } override fun isNsfwContent(): Flow = flowOf(source.isNsfw()) - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.cardSide?.updateLayoutParams { - bottomMargin = marginStart + insets.bottom - topMargin = marginStart + insets.top - } - } - override fun onOffsetChanged(appBarLayout: AppBarLayout, verticalOffset: Int) { val container = viewBinding.containerFilterHeader ?: return container.background = if (verticalOffset.absoluteValue < appBarLayout.totalScrollRange) { @@ -102,20 +96,38 @@ class MangaListActivity : } } + /** + * Only for landscape + */ + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + viewBinding.cardSide?.updateLayoutParams { + marginEnd = barsInsets.end(v) + resources.getDimensionPixelOffset(R.dimen.side_card_offset) + topMargin = barsInsets.top + resources.getDimensionPixelOffset(R.dimen.grid_spacing_outer_double) + bottomMargin = barsInsets.bottom + resources.getDimensionPixelOffset(R.dimen.side_card_offset) + } + viewBinding.appbar.updatePaddingRelative( + top = barsInsets.top, + end = if (viewBinding.cardSide == null) barsInsets.end(v) else 0, + start = barsInsets.start(v), + ) + return insets.consumeSystemBarsInsets(v, top = true, end = true) + } + override fun onClick(v: View) { when (v.id) { - R.id.button_order -> FilterSheetFragment.show(supportFragmentManager) + R.id.button_order -> router.showFilterSheet() } } fun showPreview(manga: Manga): Boolean = setSideFragment( PreviewFragment::class.java, - bundleOf(MangaIntent.KEY_MANGA to ParcelableManga(manga)), + bundleOf(AppRouter.KEY_MANGA to ParcelableManga(manga)), ) fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null) - private fun initList(source: MangaSource, filter: MangaListFilter?) { + private fun initList(source: MangaSource, filter: MangaListFilter?, sortOrder: SortOrder?) { val fm = supportFragmentManager val existingFragment = fm.findFragmentById(R.id.container) if (existingFragment is FilterCoordinator.Owner) { @@ -130,8 +142,8 @@ class MangaListActivity : } replace(R.id.container, fragment) runOnCommit { initFilter(fragment) } - if (filter != null) { - runOnCommit(ApplyFilterRunnable(fragment, filter)) + if (filter != null || sortOrder != null) { + runOnCommit(ApplyFilterRunnable(fragment, filter, sortOrder)) } } } @@ -158,7 +170,6 @@ class MangaListActivity : filter.observe().observe(this) { snapshot -> chipSort.setTextAndVisible(snapshot.sortOrder.titleRes) filterBadge.counter = if (snapshot.listFilter.hasNonSearchOptions()) 1 else 0 - supportActionBar?.subtitle = snapshot.listFilter.query } } else { filter.observe().map { @@ -186,28 +197,17 @@ class MangaListActivity : private class ApplyFilterRunnable( private val filterOwner: FilterCoordinator.Owner, - private val filter: MangaListFilter, + private val filter: MangaListFilter?, + private val sortOrder: SortOrder?, ) : Runnable { override fun run() { - filterOwner.filterCoordinator.set(filter) + if (sortOrder != null) { + filterOwner.filterCoordinator.setSortOrder(sortOrder) + } + if (filter != null) { + filterOwner.filterCoordinator.setAdjusted(filter) + } } } - - companion object { - - private const val EXTRA_FILTER = "filter" - private const val EXTRA_SOURCE = "source" - private const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA" - - fun newIntent(context: Context, source: MangaSource, filter: MangaListFilter?): Intent = - Intent(context, MangaListActivity::class.java) - .setAction(ACTION_MANGA_EXPLORE) - .putExtra(EXTRA_SOURCE, source.name) - .apply { - if (!filter.isNullOrEmpty()) { - putExtra(EXTRA_FILTER, ParcelableMangaListFilter(filter)) - } - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index c100efbcc..c7e2c6a71 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.search.ui.multi -import android.content.Context -import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuInflater @@ -9,25 +7,25 @@ import android.view.MenuItem import android.view.View import androidx.activity.viewModels import androidx.appcompat.view.ActionMode -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.util.ShareHelper +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivitySearchBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment -import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.MangaListListener @@ -35,10 +33,8 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.search.ui.MangaListActivity +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.multi.adapter.SearchAdapter import javax.inject.Inject @@ -60,16 +56,25 @@ class SearchActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySearchBinding.inflate(layoutInflater)) - title = viewModel.query - - val itemCLickListener = OnListItemClickListener { item, view -> - startActivity( - MangaListActivity.newIntent( - view.context, - item.source, - MangaListFilter(query = viewModel.query), - ), + title = when (viewModel.kind) { + SearchKind.SIMPLE, + SearchKind.TITLE -> viewModel.query + + SearchKind.AUTHOR -> getString( + R.string.inline_preference_pattern, + getString(R.string.author), + viewModel.query, ) + + SearchKind.TAG -> getString(R.string.inline_preference_pattern, getString(R.string.genre), viewModel.query) + } + + val itemClickListener = OnListItemClickListener { item, view -> + if (item.listFilter == null) { + router.openSearch(item.source, viewModel.query) + } else { + router.openList(item.source, item.listFilter, item.sortOrder) + } } val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true) val selectionDecoration = MangaSelectionDecoration(this) @@ -83,7 +88,7 @@ class SearchActivity : lifecycleOwner = this, coil = coil, listener = this, - itemClickListener = itemCLickListener, + itemClickListener = itemClickListener, sizeResolver = sizeResolver, selectionDecoration = selectionDecoration, ) @@ -91,31 +96,34 @@ class SearchActivity : viewBinding.recyclerView.setHasFixedSize(true) viewBinding.recyclerView.addItemDecoration(TypedListSpacingDecoration(this, true)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setSubtitle(R.string.search_results) - } + setDisplayHomeAsUp(true, false) + supportActionBar?.setSubtitle(R.string.search_results) + + addMenuProvider(SearchKindMenuProvider(this, viewModel.query, viewModel.kind)) viewModel.list.observe(this, adapter) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - - DownloadDialogFragment.registerCallback(this, viewBinding.recyclerView) } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.appbar.updatePadding( + top = barsInsets.top, + left = barsInsets.left, + right = barsInsets.right, ) - viewBinding.recyclerView.updatePadding( - bottom = insets.bottom + viewBinding.recyclerView.paddingTop, + viewBinding.recyclerView.setPadding( + barsInsets.left, + barsInsets.top, + barsInsets.right, + barsInsets.bottom, ) + return insets.consumeAllSystemBarsInsets() } override fun onItemClick(item: Manga, view: View) { if (!selectionController.onItemClick(item.id)) { - val intent = DetailsActivity.newIntent(this, item) - startActivity(intent) + router.openDetails(item) } } @@ -129,15 +137,13 @@ class SearchActivity : override fun onReadClick(manga: Manga, view: View) { if (!selectionController.onItemClick(manga.id)) { - val intent = IntentBuilder(this).manga(manga).build() - startActivity(intent) + router.openReader(manga) } } override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { if (!selectionController.onItemClick(manga.id)) { - val intent = MangaListActivity.newIntent(this, manga.source, MangaListFilter(tags = setOf(tag))) - startActivity(intent) + router.openList(tag) } } @@ -149,10 +155,12 @@ class SearchActivity : override fun onFilterClick(view: View?) = Unit - override fun onEmptyActionClick() = Unit + override fun onEmptyActionClick() = viewModel.continueSearch() override fun onListHeaderClick(item: ListHeader, view: View) = Unit + override fun onFooterButtonClick() = viewModel.continueSearch() + override fun onPrimaryButtonClick(tipView: TipView) = Unit override fun onSecondaryButtonClick(tipView: TipView) = Unit @@ -179,13 +187,13 @@ class SearchActivity : } R.id.action_favourite -> { - FavoriteSheet.show(supportFragmentManager, collectSelectedItems()) + router.showFavoriteDialog(collectSelectedItems()) mode?.finish() true } R.id.action_save -> { - DownloadDialogFragment.show(supportFragmentManager, collectSelectedItems()) + router.showDownloadDialog(collectSelectedItems(), viewBinding.recyclerView) mode?.finish() true } @@ -197,13 +205,4 @@ class SearchActivity : private fun collectSelectedItems(): Set { return viewModel.getItems(selectionController.peekCheckedIds()) } - - companion object { - - const val EXTRA_QUERY = "query" - - fun newIntent(context: Context, query: String) = - Intent(context, SearchActivity::class.java) - .putExtra(EXTRA_QUERY, query) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchKindMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchKindMenuProvider.kt new file mode 100644 index 000000000..47f70e6f0 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchKindMenuProvider.kt @@ -0,0 +1,56 @@ +package org.koitharu.kotatsu.search.ui.multi + +import android.os.Build +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import androidx.core.view.MenuProvider +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router +import org.koitharu.kotatsu.search.domain.SearchKind + +class SearchKindMenuProvider( + private val activity: SearchActivity, + private val query: String, + private val kind: SearchKind +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_search_kind, menu) + } + + override fun onPrepareMenu(menu: Menu) { + super.onPrepareMenu(menu) + menu.findItem( + when (kind) { + SearchKind.SIMPLE -> R.id.action_kind_simple + SearchKind.TITLE -> R.id.action_kind_title + SearchKind.AUTHOR -> R.id.action_kind_author + SearchKind.TAG -> R.id.action_kind_tag + }, + )?.isChecked = true + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + val newKind = when (menuItem.itemId) { + R.id.action_kind_simple -> SearchKind.SIMPLE + R.id.action_kind_title -> SearchKind.TITLE + R.id.action_kind_author -> SearchKind.AUTHOR + R.id.action_kind_tag -> SearchKind.TAG + else -> return false + } + if (newKind != kind) { + activity.router.openSearch( + query = query, + kind = newKind, + ) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out, 0) + } else { + activity.overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) + } + activity.finishAfterTransition() + } + return true + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt index ef6a4d5ee..1a9bf72ce 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchResultsListModel.kt @@ -6,12 +6,15 @@ import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.list.ui.ListModelDiffCallback import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.MangaListModel +import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.parsers.model.SortOrder data class SearchResultsListModel( @StringRes val titleResId: Int, val source: MangaSource, - val hasMore: Boolean, + val listFilter: MangaListFilter?, + val sortOrder: SortOrder?, val list: List, val error: Throwable?, ) : ListModel { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index c4cb96024..c4a3c2e57 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -1,21 +1,17 @@ package org.koitharu.kotatsu.search.ui.multi -import androidx.annotation.CheckResult +import androidx.collection.ArraySet import androidx.collection.LongSet import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.channelFlow import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.filterNotNull -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.onEmpty -import kotlinx.coroutines.flow.runningFold import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.joinAll import kotlinx.coroutines.launch @@ -25,50 +21,55 @@ import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource -import org.koitharu.kotatsu.core.parser.MangaRepository +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.append import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toLocale import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository import org.koitharu.kotatsu.list.domain.MangaListMapper +import org.koitharu.kotatsu.list.ui.model.ButtonFooter import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState -import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter -import org.koitharu.kotatsu.parsers.model.SortOrder +import org.koitharu.kotatsu.parsers.model.MangaParserSource +import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.util.runCatchingCancellable +import org.koitharu.kotatsu.search.domain.SearchKind +import org.koitharu.kotatsu.search.domain.SearchV2Helper +import java.util.Locale import javax.inject.Inject private const val MAX_PARALLELISM = 4 -private const val MIN_HAS_MORE_ITEMS = 8 @HiltViewModel class SearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, private val mangaListMapper: MangaListMapper, - private val mangaRepositoryFactory: MangaRepository.Factory, + private val searchHelperFactory: SearchV2Helper.Factory, private val sourcesRepository: MangaSourcesRepository, private val historyRepository: HistoryRepository, - private val localMangaRepository: LocalMangaRepository, private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { - val query = savedStateHandle.get(SearchActivity.EXTRA_QUERY).orEmpty() + val query = savedStateHandle.get(AppRouter.KEY_QUERY).orEmpty() + val kind = savedStateHandle.get(AppRouter.KEY_KIND) ?: SearchKind.SIMPLE - private val retryCounter = MutableStateFlow(0) - private val listData = retryCounter.flatMapLatest { - searchImpl(query).withLoading().withErrorHandling() - }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, null) + private var includeDisabledSources = MutableStateFlow(false) + private val results = MutableStateFlow>(emptyList()) + + private var searchJob: Job? = null val list: StateFlow> = combine( - listData.filterNotNull(), + results, isLoading, - ) { list, loading -> + includeDisabledSources, + ) { list, loading, includeDisabled -> when { list.isEmpty() -> listOf( when { @@ -83,13 +84,18 @@ class SearchViewModel @Inject constructor( ) loading -> list + LoadingFooter() - else -> list + includeDisabled -> list + else -> list + ButtonFooter(R.string.search_disabled_sources) } }.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState)) + init { + doSearch() + } + fun getItems(ids: LongSet): Set { - val snapshot = listData.value ?: return emptySet() - val result = HashSet(ids.size) + val snapshot = results.value + val result = ArraySet(ids.size) snapshot.forEach { x -> for (item in x.list) { if (item.id in ids) { @@ -101,139 +107,192 @@ class SearchViewModel @Inject constructor( } fun retry() { - retryCounter.value += 1 + searchJob?.cancel() + results.value = emptyList() + includeDisabledSources.value = false + doSearch() } - @CheckResult - private fun searchImpl(q: String): Flow> = channelFlow { - searchHistory(q)?.let { send(it) } - searchFavorites(q)?.let { send(it) } - searchLocal(q)?.let { send(it) } - val sources = sourcesRepository.getEnabledSources() - if (sources.isEmpty()) { - return@channelFlow + fun continueSearch() { + if (includeDisabledSources.value) { + return } - val semaphore = Semaphore(MAX_PARALLELISM) - sources.mapNotNull { source -> - val repository = mangaRepositoryFactory.create(source) - if (!repository.filterCapabilities.isSearchSupported) { - null - } else { + val prevJob = searchJob + searchJob = launchLoadingJob(Dispatchers.Default) { + includeDisabledSources.value = true + prevJob?.join() + val sources = sourcesRepository.getDisabledSources() + .sortedByDescending { it.priority() } + val semaphore = Semaphore(MAX_PARALLELISM) + sources.map { source -> launch { - val item = runCatchingCancellable { - semaphore.withPermit { - mangaListMapper.toListModelList( - manga = repository.getList(offset = 0, null, MangaListFilter(query = q)), - mode = ListMode.GRID, - ) - } - }.fold( - onSuccess = { list -> - if (list.isEmpty()) { - null - } else { - SearchResultsListModel(0, source, list.size > MIN_HAS_MORE_ITEMS, list, null) - } - }, - onFailure = { error -> - error.printStackTraceDebug() - SearchResultsListModel(0, source, true, emptyList(), error) - }, - ) - if (item != null) { - send(item) + semaphore.withPermit { + appendResult(searchSource(source)) } } - } - }.joinAll() - }.runningFold?>(null) { list, item -> list.orEmpty() + item } - .filterNotNull() - .onEmpty { emit(emptyList()) } - - private suspend fun searchHistory(q: String): SearchResultsListModel? { - return runCatchingCancellable { - historyRepository.search(q, Int.MAX_VALUE) - }.fold( - onSuccess = { result -> - if (result.isNotEmpty()) { - SearchResultsListModel( - titleResId = R.string.history, - source = UnknownMangaSource, - hasMore = false, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), - error = null, - ) - } else { - null + }.joinAll() + } + } + + private fun doSearch() { + val prevJob = searchJob + searchJob = launchLoadingJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + appendResult(searchHistory()) + appendResult(searchFavorites()) + appendResult(searchLocal()) + val sources = sourcesRepository.getEnabledSources() + val semaphore = Semaphore(MAX_PARALLELISM) + sources.map { source -> + launch { + semaphore.withPermit { + appendResult(searchSource(source)) + } } - }, - onFailure = { error -> + }.joinAll() + } + } + + // impl + + private suspend fun searchSource(source: MangaSource): SearchResultsListModel? = runCatchingCancellable { + val searchHelper = searchHelperFactory.create(source) + searchHelper(query, kind) + }.fold( + onSuccess = { result -> + if (result == null || result.manga.isEmpty()) { + null + } else { + val list = mangaListMapper.toListModelList( + manga = result.manga, + mode = ListMode.GRID, + ) + SearchResultsListModel( + titleResId = 0, + source = source, + list = list, + error = null, + listFilter = result.listFilter, + sortOrder = result.sortOrder, + ) + } + }, + onFailure = { error -> + error.printStackTraceDebug() + if (source is MangaParserSource && source.isBroken) { + null + } else { + SearchResultsListModel(0, source, null, null, emptyList(), error) + } + }, + ) + + private suspend fun searchHistory(): SearchResultsListModel? = runCatchingCancellable { + historyRepository.search(query, kind, Int.MAX_VALUE) + }.fold( + onSuccess = { result -> + if (result.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.history, source = UnknownMangaSource, - hasMore = false, - list = emptyList(), - error = error, + list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + error = null, + listFilter = null, + sortOrder = null, ) - }, - ) - } + } else { + null + } + }, + onFailure = { error -> + SearchResultsListModel( + titleResId = R.string.history, + source = UnknownMangaSource, + list = emptyList(), + error = error, + listFilter = null, + sortOrder = null, + ) + }, + ) - private suspend fun searchFavorites(q: String): SearchResultsListModel? { - return runCatchingCancellable { - favouritesRepository.search(q, Int.MAX_VALUE) - }.fold( - onSuccess = { result -> - if (result.isNotEmpty()) { - SearchResultsListModel( - titleResId = R.string.favourites, - source = UnknownMangaSource, - hasMore = false, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), - error = null, - ) - } else { - null - } - }, - onFailure = { error -> + private suspend fun searchFavorites(): SearchResultsListModel? = runCatchingCancellable { + favouritesRepository.search(query, kind, Int.MAX_VALUE) + }.fold( + onSuccess = { result -> + if (result.isNotEmpty()) { SearchResultsListModel( titleResId = R.string.favourites, source = UnknownMangaSource, - hasMore = false, - list = emptyList(), - error = error, + list = mangaListMapper.toListModelList( + manga = result, + mode = ListMode.GRID, + flags = MangaListMapper.NO_FAVORITE, + ), + error = null, + listFilter = null, + sortOrder = null, ) - }, - ) - } + } else { + null + } + }, + onFailure = { error -> + SearchResultsListModel( + titleResId = R.string.favourites, + source = UnknownMangaSource, + list = emptyList(), + error = error, + listFilter = null, + sortOrder = null, + ) + }, + ) - private suspend fun searchLocal(q: String): SearchResultsListModel? { - return runCatchingCancellable { - localMangaRepository.getList(0, SortOrder.RELEVANCE, MangaListFilter(query = q)) - }.fold( - onSuccess = { result -> - if (result.isNotEmpty()) { - SearchResultsListModel( - titleResId = 0, - source = LocalMangaSource, - hasMore = result.size > MIN_HAS_MORE_ITEMS, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), - error = null, - ) - } else { - null - } - }, - onFailure = { error -> + private suspend fun searchLocal(): SearchResultsListModel? = runCatchingCancellable { + searchHelperFactory.create(LocalMangaSource).invoke(query, kind) + }.fold( + onSuccess = { result -> + if (!result?.manga.isNullOrEmpty()) { SearchResultsListModel( titleResId = 0, source = LocalMangaSource, - hasMore = true, - list = emptyList(), - error = error, + list = mangaListMapper.toListModelList( + manga = result.manga, + mode = ListMode.GRID, + flags = MangaListMapper.NO_SAVED, + ), + error = null, + listFilter = result.listFilter, + sortOrder = result.sortOrder, ) - }, - ) + } else { + null + } + }, + onFailure = { error -> + SearchResultsListModel( + titleResId = 0, + source = LocalMangaSource, + list = emptyList(), + error = error, + listFilter = null, + sortOrder = null, + ) + }, + ) + + private fun appendResult(item: SearchResultsListModel?) { + if (item != null) { + results.append(item) + } + } + + private fun MangaSource.priority(): Int { + var res = 0 + if (this is MangaParserSource) { + if (locale.toLocale() == Locale.getDefault()) res += 2 + } + return res } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt index d14206a68..a7c7323dd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchAdapter.kt @@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.ui.list.fastscroll.FastScroller import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.MangaListListener +import org.koitharu.kotatsu.list.ui.adapter.buttonFooterAD import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD import org.koitharu.kotatsu.list.ui.adapter.errorStateListAD import org.koitharu.kotatsu.list.ui.adapter.loadingFooterAD @@ -45,6 +46,7 @@ class SearchAdapter( addDelegate(ListItemType.FOOTER_LOADING, loadingFooterAD()) addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, listener)) addDelegate(ListItemType.STATE_ERROR, errorStateListAD(listener)) + addDelegate(ListItemType.FOOTER_BUTTON, buttonFooterAD(listener)) } override fun getSectionText(context: Context, position: Int): CharSequence? { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt index f7c2c13f4..989e97f2b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/adapter/SearchResultsAD.kt @@ -8,6 +8,7 @@ import coil3.ImageLoader import com.hannesdorfmann.adapterdelegates4.ListDelegationAdapter import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.decor.SpacingItemDecoration @@ -46,7 +47,7 @@ fun searchResultsAD( bind { binding.textViewTitle.text = item.getTitle(context) - binding.buttonMore.isVisible = item.hasMore + binding.buttonMore.isVisible = item.source !== UnknownMangaSource adapter.items = item.list adapter.notifyDataSetChanged() binding.recyclerView.isGone = item.list.isEmpty() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt index 49b7d467c..08bc21bdf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -2,17 +2,17 @@ package org.koitharu.kotatsu.search.ui.suggestion import android.os.Bundle import android.view.LayoutInflater +import android.view.View import android.view.ViewGroup -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.activityViewModels import androidx.recyclerview.widget.ItemTouchHelper import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.os.VoiceInputContract import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.FragmentSearchSuggestionBinding import org.koitharu.kotatsu.search.ui.suggestion.adapter.SearchSuggestionAdapter @@ -53,14 +53,15 @@ class SearchSuggestionFragment : .attachToRecyclerView(binding.root) } - override fun onWindowInsetsChanged(insets: Insets) { - val extraPadding = resources.getDimensionPixelOffset(R.dimen.list_spacing) - requireViewBinding().root.updatePadding( - top = extraPadding, - right = insets.right, - left = insets.left, - bottom = insets.bottom, + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()) + v.setPadding( + barsInsets.left, + 0, + barsInsets.right, + barsInsets.bottom, ) + return insets.consumeAllSystemBarsInsets() } override fun onRemoveQuery(query: String) { @@ -71,16 +72,4 @@ class SearchSuggestionFragment : super.onResume() viewModel.onResume() } - - companion object { - - @Deprecated( - "", - ReplaceWith( - "SearchSuggestionFragment()", - "org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment", - ), - ) - fun newInstance() = SearchSuggestionFragment() - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt index 0a35e13a7..7a89cac70 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionListener.kt @@ -3,12 +3,13 @@ package org.koitharu.kotatsu.search.ui.suggestion import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag +import org.koitharu.kotatsu.search.domain.SearchKind interface SearchSuggestionListener { fun onMangaClick(manga: Manga) - fun onQueryClick(query: String, submit: Boolean) + fun onQueryClick(query: String, kind: SearchKind, submit: Boolean) fun onQueryChanged(query: String) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAuthorAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAuthorAD.kt index f59b0d785..338309243 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAuthorAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionAuthorAD.kt @@ -4,6 +4,7 @@ import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem @@ -14,7 +15,7 @@ fun searchSuggestionAuthorAD( ) { val viewClickListener = View.OnClickListener { _ -> - listener.onQueryClick(item.name, true) + listener.onQueryClick(item.name, SearchKind.AUTHOR, true) } binding.root.setCompoundDrawablesRelativeWithIntrinsicBounds(R.drawable.ic_user, 0, 0, 0) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt index 70854cb2f..6fd65df1d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryAD.kt @@ -4,23 +4,25 @@ import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.R import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryBinding +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem fun searchSuggestionQueryAD( listener: SearchSuggestionListener, -) = adapterDelegateViewBinding( - { inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) } -) { +) = + adapterDelegateViewBinding( + { inflater, parent -> ItemSearchSuggestionQueryBinding.inflate(inflater, parent, false) }, + ) { - val viewClickListener = View.OnClickListener { v -> - listener.onQueryClick(item.query, v.id != R.id.button_complete) - } + val viewClickListener = View.OnClickListener { v -> + listener.onQueryClick(item.query, SearchKind.SIMPLE, v.id != R.id.button_complete) + } - binding.root.setOnClickListener(viewClickListener) - binding.buttonComplete.setOnClickListener(viewClickListener) + binding.root.setOnClickListener(viewClickListener) + binding.buttonComplete.setOnClickListener(viewClickListener) - bind { - binding.textViewTitle.text = item.query + bind { + binding.textViewTitle.text = item.query + } } -} \ No newline at end of file diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt index ede12ed52..f236603be 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/adapter/SearchSuggestionQueryHintAD.kt @@ -3,6 +3,7 @@ package org.koitharu.kotatsu.search.ui.suggestion.adapter import android.view.View import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding import org.koitharu.kotatsu.databinding.ItemSearchSuggestionQueryHintBinding +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import org.koitharu.kotatsu.search.ui.suggestion.model.SearchSuggestionItem @@ -13,7 +14,7 @@ fun searchSuggestionQueryHintAD( ) { val viewClickListener = View.OnClickListener { _ -> - listener.onQueryClick(item.query, true) + listener.onQueryClick(item.query, SearchKind.SIMPLE, true) } binding.root.setOnClickListener(viewClickListener) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt index 914f5ec69..1950674de 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/widget/SearchEditText.kt @@ -21,6 +21,7 @@ import androidx.core.content.ContextCompat import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.util.ext.drawableEnd import org.koitharu.kotatsu.core.util.ext.drawableStart +import org.koitharu.kotatsu.search.domain.SearchKind import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener import com.google.android.material.R as materialR @@ -66,7 +67,7 @@ class SearchEditText @JvmOverloads constructor( && query.isNotEmpty() ) { cancelLongPress() - searchSuggestionListener?.onQueryClick(query, submit = true) + searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true) clearFocus() return true } @@ -76,7 +77,7 @@ class SearchEditText @JvmOverloads constructor( override fun onEditorAction(actionCode: Int) { super.onEditorAction(actionCode) if (actionCode == EditorInfo.IME_ACTION_SEARCH) { - searchSuggestionListener?.onQueryClick(query, submit = true) + searchSuggestionListener?.onQueryClick(query, SearchKind.SIMPLE, submit = true) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt index f00a95fa4..9a0b7b6fe 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/AppearanceSettingsFragment.kt @@ -9,6 +9,7 @@ import android.provider.Settings import android.view.View import androidx.appcompat.app.AppCompatDelegate import androidx.preference.ListPreference +import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R @@ -26,6 +27,7 @@ import org.koitharu.kotatsu.core.util.ext.toList import org.koitharu.kotatsu.parsers.util.names import org.koitharu.kotatsu.parsers.util.toTitleCase import org.koitharu.kotatsu.settings.utils.ActivityListPreference +import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider import org.koitharu.kotatsu.settings.utils.SliderPreference import javax.inject.Inject @@ -63,6 +65,9 @@ class AppearanceSettingsFragment : } setDefaultValueCompat("") } + findPreference(AppSettings.KEY_MANGA_LIST_BADGES)?.run { + summaryProvider = MultiSummaryProvider(R.string.none) + } bindNavSummary() } @@ -84,7 +89,7 @@ class AppearanceSettingsFragment : AppSettings.KEY_COLOR_THEME, AppSettings.KEY_THEME_AMOLED, - -> { + -> { postRestart() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt index eece51d8f..c2d17832c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt @@ -5,7 +5,6 @@ import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import android.view.View -import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile import androidx.preference.ListPreference import androidx.preference.Preference @@ -15,10 +14,13 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router +import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.DownloadFormat import org.koitharu.kotatsu.core.prefs.TriStateOption import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.resolveFile import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat @@ -27,8 +29,6 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog -import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity import org.koitharu.kotatsu.settings.utils.DozeHelper import javax.inject.Inject @@ -45,7 +45,7 @@ class DownloadsSettingsFragment : @Inject lateinit var downloadsScheduler: DownloadWorker.Scheduler - private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { + private val pickFileTreeLauncher = OpenDocumentTreeHelper(this) { if (it != null) onDirectoryPicked(it) } @@ -98,12 +98,12 @@ class DownloadsSettingsFragment : override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_LOCAL_STORAGE -> { - MangaDirectorySelectDialog.show(childFragmentManager) + router.showDirectorySelectDialog() true } AppSettings.KEY_LOCAL_MANGA_DIRS -> { - startActivity(MangaDirectoriesActivity.newIntent(preference.context)) + router.openDirectoriesSettings() true } @@ -146,7 +146,7 @@ class DownloadsSettingsFragment : private fun Preference.bindDirectoriesCount() { viewLifecycleScope.launch { val dirs = storageManager.getReadableDirs().size - summary = resources.getQuantityString(R.plurals.items, dirs, dirs) + summary = resources.getQuantityStringSafe(R.plurals.items, dirs, dirs) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt index c0203869f..513e33f5f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ProxySettingsFragment.kt @@ -43,6 +43,7 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_proxy) + @Suppress("UsePropertyAccessSyntax") findPreference(AppSettings.KEY_PROXY_ADDRESS)?.setOnBindEditTextListener( EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_URI, @@ -50,6 +51,7 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), validator = DomainValidator(), ), ) + @Suppress("UsePropertyAccessSyntax") findPreference(AppSettings.KEY_PROXY_PORT)?.setOnBindEditTextListener( EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_NUMBER, @@ -58,6 +60,7 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), ), ) findPreference(AppSettings.KEY_PROXY_PASSWORD)?.let { pref -> + @Suppress("UsePropertyAccessSyntax") pref.setOnBindEditTextListener( EditTextBindListener( inputType = EditorInfo.TYPE_CLASS_TEXT or EditorInfo.TYPE_TEXT_VARIATION_PASSWORD, @@ -119,8 +122,9 @@ class ProxySettingsFragment : BasePreferenceFragment(R.string.proxy), .get() .url("http://neverssl.com") .build() - val response = okHttpClient.newCall(request).await() - check(response.isSuccessful) { response.message } + okHttpClient.newCall(request).await().use { response -> + check(response.isSuccessful) { response.message } + } } showTestResult(null) } catch (e: CancellationException) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt index 052a1e4c1..439180a81 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.settings -import android.content.Intent import android.content.SharedPreferences import android.content.pm.ActivityInfo import android.os.Bundle @@ -11,14 +10,16 @@ import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.ZoomMode +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderAnimation import org.koitharu.kotatsu.core.prefs.ReaderBackground +import org.koitharu.kotatsu.core.prefs.ReaderControl import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider import org.koitharu.kotatsu.settings.utils.SliderPreference @@ -31,12 +32,7 @@ class ReaderSettingsFragment : override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_reader) findPreference(AppSettings.KEY_READER_MODE)?.run { - entryValues = arrayOf( - ReaderMode.STANDARD.name, - ReaderMode.REVERSED.name, - ReaderMode.VERTICAL.name, - ReaderMode.WEBTOON.name, - ) + entryValues = ReaderMode.entries.names() setDefaultValueCompat(ReaderMode.STANDARD.name) } findPreference(AppSettings.KEY_READER_ORIENTATION)?.run { @@ -48,6 +44,11 @@ class ReaderSettingsFragment : ) setDefaultValueCompat(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED.toString()) } + findPreference(AppSettings.KEY_READER_CONTROLS)?.run { + entryValues = ReaderControl.entries.names() + setDefaultValueCompat(ReaderControl.DEFAULT.mapToSet { it.name }) + summaryProvider = MultiSummaryProvider(R.string.none) + } findPreference(AppSettings.KEY_READER_BACKGROUND)?.run { entryValues = ReaderBackground.entries.names() setDefaultValueCompat(ReaderBackground.DEFAULT.name) @@ -80,7 +81,7 @@ class ReaderSettingsFragment : override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_READER_TAP_ACTIONS -> { - startActivity(Intent(preference.context, ReaderTapGridConfigActivity::class.java)) + router.openReaderTapGridSettings() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt index eef3efd0c..1fd9f1449 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/RootSettingsFragment.kt @@ -12,6 +12,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.settings.search.SettingsSearchMenuProvider import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel @@ -42,7 +43,7 @@ class RootSettingsFragment : BasePreferenceFragment(0) { pref.summary = if (it >= 0) { getString(R.string.enabled_d_of_d, it, total) } else { - resources.getQuantityString(R.plurals.items, total, total) + resources.getQuantityStringSafe(R.plurals.items, total, total) } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt index af1f7a465..556cb481a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.settings import android.accounts.AccountManager -import android.content.Intent import android.content.SharedPreferences import android.os.Bundle import android.view.View @@ -12,6 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog @@ -20,11 +20,8 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper -import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference -import org.koitharu.kotatsu.stats.ui.StatsActivity import org.koitharu.kotatsu.sync.domain.SyncController -import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent import javax.inject.Inject @AndroidEntryPoint @@ -41,7 +38,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), addPreferencesFromResource(R.xml.pref_services) findPreference(AppSettings.KEY_STATS_ENABLED)?.let { it.onContainerClickListener = Preference.OnPreferenceClickListener { - it.context.startActivity(Intent(it.context, StatsActivity::class.java)) + router.openStatistic() true } } @@ -105,7 +102,9 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), if (account == null) { am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) } else { - startActivitySafe(SyncSettingsIntent(account)) + if (!router.openSystemSyncSettings(account)) { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + } } true } @@ -146,7 +145,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), if (!scrobblerAuthHelper.isAuthorized(scrobblerService)) { confirmScrobblerAuth(scrobblerService) } else { - startActivity(ScrobblerConfigActivity.newIntent(context ?: return, scrobblerService)) + router.openScrobblerSettings(scrobblerService) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt index 18c86b69e..93b8c6b53 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -1,16 +1,14 @@ package org.koitharu.kotatsu.settings -import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.provider.Settings -import android.view.ViewGroup.MarginLayoutParams +import android.view.View +import android.view.ViewGroup import androidx.activity.viewModels -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams -import androidx.core.view.updatePadding +import androidx.core.view.updatePaddingRelative import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentFactory import androidx.fragment.app.FragmentTransaction @@ -19,23 +17,24 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.google.android.material.appbar.AppBarLayout import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.model.MangaSourceInfo -import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.ext.buildBundle +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.settings.about.AboutSettingsFragment import org.koitharu.kotatsu.settings.search.SettingsItem import org.koitharu.kotatsu.settings.search.SettingsSearchFragment import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment +import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment.Companion.EXTRA_SOURCE import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment @@ -53,15 +52,12 @@ class SettingsActivity : private val isMasterDetails get() = viewBinding.containerMaster != null - private var screenPadding = 0 - private val viewModel: SettingsSearchViewModel by viewModels() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySettingsBinding.inflate(layoutInflater)) - screenPadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUp(true, false) val fm = supportFragmentManager val currentFragment = fm.findFragmentById(R.id.container) if (currentFragment == null || (isMasterDetails && currentFragment is RootSettingsFragment)) { @@ -77,6 +73,21 @@ class SettingsActivity : viewModel.onNavigateToPreference.observeEvent(this, ::navigateToPreference) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val isTablet = viewBinding.containerMaster != null + viewBinding.appbar.updatePaddingRelative( + start = bars.start(v), + top = bars.top, + end = if (isTablet) 0 else bars.end(v), + ) + viewBinding.textViewHeader?.updateLayoutParams { + marginEnd = bars.end(v) + topMargin = bars.top + } + return insets + } + override fun onPreferenceStartFragment( caller: PreferenceFragmentCompat, pref: Preference, @@ -90,16 +101,6 @@ class SettingsActivity : return true } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - viewBinding.textViewHeader?.updateLayoutParams { - topMargin = screenPadding + insets.top - } - } - fun setSectionTitle(title: CharSequence?) { viewBinding.textViewHeader?.apply { textAndVisible = title @@ -142,18 +143,18 @@ class SettingsActivity : private fun openDefaultFragment() { val fragment = when (intent?.action) { - ACTION_READER -> ReaderSettingsFragment() - ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() - ACTION_HISTORY -> UserDataSettingsFragment() - ACTION_TRACKER -> TrackerSettingsFragment() - ACTION_SOURCES -> SourcesSettingsFragment() - ACTION_PROXY -> ProxySettingsFragment() - ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment() - ACTION_SOURCE -> SourceSettingsFragment.newInstance( + AppRouter.ACTION_READER -> ReaderSettingsFragment() + AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() + AppRouter.ACTION_HISTORY -> UserDataSettingsFragment() + AppRouter.ACTION_TRACKER -> TrackerSettingsFragment() + AppRouter.ACTION_SOURCES -> SourcesSettingsFragment() + AppRouter.ACTION_PROXY -> ProxySettingsFragment() + AppRouter.ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment() + AppRouter.ACTION_SOURCE -> SourceSettingsFragment.newInstance( MangaSource(intent.getStringExtra(EXTRA_SOURCE)), ) - ACTION_MANAGE_SOURCES -> SourcesManageFragment() + AppRouter.ACTION_MANAGE_SOURCES -> SourcesManageFragment() Intent.ACTION_VIEW -> { when (intent.data?.host) { HOST_ABOUT -> AboutSettingsFragment() @@ -171,7 +172,7 @@ class SettingsActivity : } private fun navigateToPreference(item: SettingsItem) { - val args = Bundle(1).apply { + val args = buildBundle(1) { putString(ARG_PREF_KEY, item.key) } openFragment(item.fragmentClass, args, true) @@ -179,62 +180,8 @@ class SettingsActivity : companion object { - private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" - private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" - private const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER" - private const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY" - private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" - private const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES" - private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST" - private const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS" - private const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY" - private const val EXTRA_SOURCE = "source" private const val HOST_ABOUT = "about" private const val HOST_SYNC_SETTINGS = "sync-settings" const val ARG_PREF_KEY = "pref_key" - - fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java) - - fun newReaderSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_READER) - - fun newSuggestionsSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_SUGGESTIONS) - - fun newTrackerSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_TRACKER) - - fun newProxySettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_PROXY) - - fun newHistorySettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_HISTORY) - - fun newSourcesSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_SOURCES) - - fun newManageSourcesIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_MANAGE_SOURCES) - - fun newDownloadsSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_MANAGE_DOWNLOADS) - - fun newSourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) { - is MangaSourceInfo -> newSourceSettingsIntent(context, source.mangaSource) - is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(Uri.fromParts("package", source.packageName, null)) - - else -> Intent(context, SettingsActivity::class.java) - .setAction(ACTION_SOURCE) - .putExtra(EXTRA_SOURCE, source.name) - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt index bbc9ee88b..e41d756a0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.os.Bundle import android.view.View import androidx.annotation.StringRes -import androidx.core.net.toUri import androidx.fragment.app.viewModels import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat @@ -16,6 +15,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.github.VersionId import org.koitharu.kotatsu.core.github.isStable +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.observe @@ -89,15 +89,13 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { } } - private fun openLink(@StringRes url: Int, title: CharSequence?): Boolean { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = getString(url).toUri() - return startActivitySafe( - if (title != null) { - Intent.createChooser(intent, title) - } else { - intent - }, - ) + private fun openLink( + @StringRes url: Int, + title: CharSequence? + ): Boolean = if (router.openExternalBrowser(getString(url), title)) { + true + } else { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + false } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt index 01bdc2b4a..ecd0d1d78 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt @@ -14,9 +14,9 @@ import android.widget.TextView import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.content.ContextCompat -import androidx.core.graphics.Insets -import androidx.core.net.toUri import androidx.core.text.buildSpannedString +import androidx.core.view.WindowInsetsCompat +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import io.noties.markwon.Markwon import kotlinx.coroutines.Dispatchers @@ -24,14 +24,17 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppVersion +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.showOrHide +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivityAppUpdateBinding @@ -83,6 +86,21 @@ class AppUpdateActivity : BaseActivity(), View.OnClick super.onDestroy() } + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) + viewBinding.root.setPadding( + barsInsets.left + basePadding, + barsInsets.top + basePadding, + barsInsets.right + basePadding, + barsInsets.bottom + basePadding, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> finishAfterTransition() @@ -90,16 +108,6 @@ class AppUpdateActivity : BaseActivity(), View.OnClick } } - override fun onWindowInsetsChanged(insets: Insets) { - val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) - viewBinding.root.setPadding( - basePadding + insets.left, - basePadding + insets.top, - basePadding + insets.right, - basePadding + insets.bottom, - ) - } - private suspend fun onNextVersionChanged(version: AppVersion?) { viewBinding.buttonUpdate.isEnabled = version != null && !viewModel.isLoading.value if (version == null) { @@ -137,8 +145,9 @@ class AppUpdateActivity : BaseActivity(), View.OnClick private fun openInBrowser() { val latestVersion = viewModel.nextVersion.value ?: return - val intent = Intent(Intent.ACTION_VIEW, latestVersion.url.toUri()) - startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser))) + if (!router.openExternalBrowser(latestVersion.url, getString(R.string.open_in_browser))) { + Snackbar.make(viewBinding.scrollView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + } } private fun onProgressChanged(value: Pair) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt index 61b64637a..e906b6f31 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.exceptions.BadBackupFormatException import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.reader.data.TapGridSettings import java.io.File import java.io.FileDescriptor import java.io.FileInputStream @@ -38,7 +39,14 @@ class AppBackupAgent : BackupAgent() { override fun onFullBackup(data: FullBackupDataOutput) { super.onFullBackup(data) val file = - createBackupFile(this, BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext))) + createBackupFile( + this, + BackupRepository( + MangaDatabase(context = applicationContext), + AppSettings(applicationContext), + TapGridSettings(applicationContext), + ), + ) try { fullBackupFile(file, data) } finally { @@ -58,7 +66,11 @@ class AppBackupAgent : BackupAgent() { restoreBackupFile( data.fileDescriptor, size, - BackupRepository(MangaDatabase(applicationContext), AppSettings(applicationContext)), + BackupRepository( + db = MangaDatabase(applicationContext), + settings = AppSettings(applicationContext), + tapGridSettings = TapGridSettings(applicationContext), + ), ) destination.delete() } else { @@ -76,6 +88,7 @@ class AppBackupAgent : BackupAgent() { backup.put(repository.dumpBookmarks()) backup.put(repository.dumpSources()) backup.put(repository.dumpSettings()) + backup.put(repository.dumpReaderGridSettings()) backup.finish() backup.file } @@ -97,12 +110,13 @@ class AppBackupAgent : BackupAgent() { } try { runBlocking { - backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) } + backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it, null) } backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) } - backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) } + backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it, null) } backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) } backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) } backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) } + backup.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let { repository.restoreReaderGridSettings(it) } } } finally { backup.close() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt index b40157ab0..c26c02f98 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt @@ -1,13 +1,11 @@ package org.koitharu.kotatsu.settings.backup -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isVisible -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint @@ -17,23 +15,20 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.tryLaunch +import org.koitharu.kotatsu.core.util.progress.Progress import org.koitharu.kotatsu.databinding.DialogProgressBinding import java.io.File -import java.io.FileOutputStream -import kotlin.math.roundToInt @AndroidEntryPoint class BackupDialogFragment : AlertDialogFragment() { private val viewModel by viewModels() - private var backup: File? = null private val saveFileContract = registerForActivityResult( ActivityResultContracts.CreateDocument("application/zip"), ) { uri -> - val file = backup - if (uri != null && file != null) { - saveBackup(file, uri) + if (uri != null) { + viewModel.saveBackup(uri) } else { dismiss() } @@ -52,6 +47,7 @@ class BackupDialogFragment : AlertDialogFragment() { viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) viewModel.onBackupDone.observeEvent(viewLifecycleOwner, this::onBackupDone) viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) + viewModel.onBackupSaved.observeEvent(viewLifecycleOwner) { onBackupSaved() } } override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder { @@ -69,47 +65,27 @@ class BackupDialogFragment : AlertDialogFragment() { dismiss() } - private fun onProgressChanged(value: Float) { + private fun onProgressChanged(value: Progress) { with(requireViewBinding().progressBar) { isVisible = true val wasIndeterminate = isIndeterminate - isIndeterminate = value < 0 - if (value >= 0) { - setProgressCompat((value * max).roundToInt(), !wasIndeterminate) + isIndeterminate = value.isIndeterminate + if (!value.isIndeterminate) { + max = value.total + setProgressCompat(value.progress, !wasIndeterminate) } } } private fun onBackupDone(file: File) { - this.backup = file if (!saveFileContract.tryLaunch(file.name)) { Toast.makeText(requireContext(), R.string.operation_not_supported, Toast.LENGTH_SHORT).show() dismiss() } } - private fun saveBackup(file: File, output: Uri) { - try { - requireContext().contentResolver.openFileDescriptor(output, "w")?.use { fd -> - FileOutputStream(fd.fileDescriptor).use { - it.write(file.readBytes()) - } - } - Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show() - dismiss() - } catch (e: InterruptedException) { - throw e - } catch (e: Exception) { - onError(e) - } - } - - companion object { - - private const val TAG = "BackupDialogFragment" - - fun show(fm: FragmentManager) { - BackupDialogFragment().show(fm, TAG) - } + private fun onBackupSaved() { + Toast.makeText(requireContext(), R.string.backup_saved, Toast.LENGTH_SHORT).show() + dismiss() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt index 632807dec..aede79ca3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupEntryModel.kt @@ -20,6 +20,7 @@ data class BackupEntryModel( BackupEntry.Name.CATEGORIES -> R.string.favourites_categories BackupEntry.Name.FAVOURITES -> R.string.favourites BackupEntry.Name.SETTINGS -> R.string.settings + BackupEntry.Name.SETTINGS_READER_GRID -> R.string.reader_actions BackupEntry.Name.BOOKMARKS -> R.string.bookmarks BackupEntry.Name.SOURCES -> R.string.remote_sources } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt index 62e21aa00..484b8a8ff 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupViewModel.kt @@ -1,15 +1,21 @@ package org.koitharu.kotatsu.settings.backup +import android.content.ContentResolver import android.content.Context +import android.net.Uri import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow +import okio.FileNotFoundException import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.progress.Progress import java.io.File +import java.io.FileOutputStream import javax.inject.Inject @HiltViewModel @@ -18,38 +24,57 @@ class BackupViewModel @Inject constructor( @ApplicationContext context: Context, ) : BaseViewModel() { - val progress = MutableStateFlow(-1f) + val progress = MutableStateFlow(Progress.INDETERMINATE) val onBackupDone = MutableEventFlow() + val onBackupSaved = MutableEventFlow() + + private val contentResolver: ContentResolver = context.contentResolver + private var backupFile: File? = null init { - launchLoadingJob { + launchLoadingJob(Dispatchers.Default) { val file = BackupZipOutput.createTemp(context).use { backup -> - val step = 1f / 6f + progress.value = Progress(0, 7) backup.put(repository.createIndex()) - progress.value = 0f backup.put(repository.dumpHistory()) + progress.value = progress.value.inc() - progress.value += step backup.put(repository.dumpCategories()) + progress.value = progress.value.inc() - progress.value += step backup.put(repository.dumpFavourites()) + progress.value = progress.value.inc() - progress.value += step backup.put(repository.dumpBookmarks()) + progress.value = progress.value.inc() - progress.value += step backup.put(repository.dumpSources()) + progress.value = progress.value.inc() - progress.value += step backup.put(repository.dumpSettings()) + progress.value = progress.value.inc() + + backup.put(repository.dumpReaderGridSettings()) + progress.value = progress.value.inc() backup.finish() - progress.value = 1f backup.file } + backupFile = file onBackupDone.call(file) } } + + fun saveBackup(output: Uri) { + launchLoadingJob(Dispatchers.Default) { + val file = backupFile ?: throw FileNotFoundException() + contentResolver.openFileDescriptor(output, "w")?.use { fd -> + FileOutputStream(fd.fileDescriptor).use { + it.write(file.readBytes()) + } + } + onBackupSaved.call(Unit) + } + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt index 51b90f8e0..06f882881 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt @@ -5,6 +5,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.backup.ExternalBackupStorage +import org.koitharu.kotatsu.core.backup.TelegramBackupUploader import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.CoroutineIntentService import javax.inject.Inject @@ -15,6 +16,9 @@ class PeriodicalBackupService : CoroutineIntentService() { @Inject lateinit var externalBackupStorage: ExternalBackupStorage + @Inject + lateinit var telegramBackupUploader: TelegramBackupUploader + @Inject lateinit var repository: BackupRepository @@ -39,10 +43,14 @@ class PeriodicalBackupService : CoroutineIntentService() { backup.put(repository.dumpBookmarks()) backup.put(repository.dumpSources()) backup.put(repository.dumpSettings()) + backup.put(repository.dumpReaderGridSettings()) backup.finish() } externalBackupStorage.put(output.file) externalBackupStorage.trim(settings.periodicalBackupMaxCount) + if (settings.isBackupTelegramUploadEnabled) { + telegramBackupUploader.uploadBackup(output.file) + } } finally { output.file.delete() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt index 4e8e9ddb4..37262769a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt @@ -1,28 +1,28 @@ package org.koitharu.kotatsu.settings.backup -import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import android.text.format.DateUtils import android.view.View import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.viewModels +import androidx.preference.EditTextPreference import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar 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.BackupZipOutput.Companion.DIR_BACKUPS -import org.koitharu.kotatsu.core.backup.ExternalBackupStorage +import org.koitharu.kotatsu.core.backup.TelegramBackupUploader +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router +import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper 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.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.tryLaunch -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import java.io.File +import org.koitharu.kotatsu.settings.utils.EditTextFallbackSummaryProvider +import java.util.Date import javax.inject.Inject @AndroidEntryPoint @@ -30,28 +30,43 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi ActivityResultCallback { @Inject - lateinit var backupStorage: ExternalBackupStorage + lateinit var telegramBackupUploader: TelegramBackupUploader - private val outputSelectCall = registerForActivityResult( - ActivityResultContracts.OpenDocumentTree(), - this, - ) + private val viewModel by viewModels() + + private val outputSelectCall = OpenDocumentTreeHelper(this, this) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_backup_periodic) + findPreference(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider = + EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - bindOutputSummary() - bindLastBackupInfo() + viewModel.lastBackupDate.observe(viewLifecycleOwner, ::bindLastBackupInfo) + viewModel.backupsDirectory.observe(viewLifecycleOwner, ::bindOutputSummary) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) + viewModel.isTelegramCheckLoading.observe(viewLifecycleOwner) { + findPreference(AppSettings.KEY_BACKUP_TG_TEST)?.isEnabled = !it + } } override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { + val result = when (preference.key) { AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null) - else -> super.onPreferenceTreeClick(preference) + AppSettings.KEY_BACKUP_TG_OPEN -> telegramBackupUploader.openBotInApp(router) + AppSettings.KEY_BACKUP_TG_TEST -> { + viewModel.checkTelegram() + true + } + + else -> return super.onPreferenceTreeClick(preference) + } + if (!result) { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } + return true } override fun onActivityResult(result: Uri?) { @@ -59,44 +74,28 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION context?.contentResolver?.takePersistableUriPermission(result, takeFlags) settings.periodicalBackupDirectory = result - bindOutputSummary() - bindLastBackupInfo() + viewModel.updateSummaryData() } } - private fun bindOutputSummary() { + private fun bindOutputSummary(path: String?) { val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return - viewLifecycleScope.launch { - preference.summary = withContext(Dispatchers.Default) { - val value = settings.periodicalBackupDirectory - value?.toUserFriendlyString(preference.context) ?: preference.context.run { - getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) - }.path - } + preference.summary = when (path) { + null -> getString(R.string.invalid_value_message) + "" -> null + else -> path } } - private fun bindLastBackupInfo() { + private fun bindLastBackupInfo(lastBackupDate: Date?) { val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return - viewLifecycleScope.launch { - val lastDate = withContext(Dispatchers.Default) { - backupStorage.getLastBackupDate() - } - preference.summary = lastDate?.let { - preference.context.getString( - R.string.last_successful_backup, - DateUtils.getRelativeTimeSpanString(it.time), - ) - } - 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) + preference.summary = lastBackupDate?.let { + preference.context.getString( + R.string.last_successful_backup, + DateUtils.getRelativeTimeSpanString(it.time), + ) } - return resolveFile(context)?.path ?: toString() + preference.isVisible = lastBackupDate != null } } + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt new file mode 100644 index 000000000..f557ed11d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt @@ -0,0 +1,78 @@ +package org.koitharu.kotatsu.settings.backup + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.backup.BackupZipOutput.Companion.DIR_BACKUPS +import org.koitharu.kotatsu.core.backup.ExternalBackupStorage +import org.koitharu.kotatsu.core.backup.TelegramBackupUploader +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.util.ReversibleAction +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.resolveFile +import java.io.File +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class PeriodicalBackupSettingsViewModel @Inject constructor( + private val settings: AppSettings, + private val telegramUploader: TelegramBackupUploader, + private val backupStorage: ExternalBackupStorage, + @ApplicationContext private val appContext: Context, +) : BaseViewModel() { + + val lastBackupDate = MutableStateFlow(null) + val backupsDirectory = MutableStateFlow("") + val isTelegramCheckLoading = MutableStateFlow(false) + val onActionDone = MutableEventFlow() + + init { + updateSummaryData() + } + + fun checkTelegram() { + launchJob(Dispatchers.Default) { + try { + isTelegramCheckLoading.value = true + telegramUploader.sendTestMessage() + onActionDone.call(ReversibleAction(R.string.connection_ok, null)) + } finally { + isTelegramCheckLoading.value = false + } + } + } + + fun updateSummaryData() { + updateBackupsDirectory() + updateLastBackupDate() + } + + private fun updateBackupsDirectory() = launchJob(Dispatchers.Default) { + val dir = settings.periodicalBackupDirectory + backupsDirectory.value = if (dir != null) { + dir.toUserFriendlyString() + } else { + (appContext.getExternalFilesDir(DIR_BACKUPS) ?: File(appContext.filesDir, DIR_BACKUPS)).path + } + } + + private fun updateLastBackupDate() = launchJob(Dispatchers.Default) { + lastBackupDate.value = backupStorage.getLastBackupDate() + } + + private fun Uri.toUserFriendlyString(): String? { + val df = DocumentFile.fromTreeUri(appContext, this) + if (df?.canWrite() != true) { + return null + } + return resolveFile(appContext)?.path ?: toString() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt index 11e16e4e2..efa040479 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt @@ -1,32 +1,28 @@ package org.koitharu.kotatsu.settings.backup -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.backup.CompositeResult +import org.koitharu.kotatsu.core.nav.router 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.observe 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.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 @AndroidEntryPoint class RestoreDialogFragment : AlertDialogFragment(), OnListItemClickListener, @@ -46,8 +42,6 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis binding.buttonCancel.setOnClickListener(this) binding.buttonRestore.setOnClickListener(this) viewModel.availableEntries.observe(viewLifecycleOwner, adapter) - viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) - viewModel.onRestoreDone.observeEvent(viewLifecycleOwner, this::onRestoreDone) viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) combine( viewModel.isLoading, @@ -66,7 +60,15 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> dismiss() - R.id.button_restore -> viewModel.restore() + R.id.button_restore -> { + if (startRestoreService()) { + Toast.makeText(v.context, R.string.backup_restored_background, Toast.LENGTH_SHORT).show() + router.closeWelcomeSheet() + dismiss() + } else { + Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() + } + } } } @@ -90,6 +92,14 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis } } + private fun startRestoreService(): Boolean { + return RestoreService.start( + context ?: return false, + viewModel.uri ?: return false, + viewModel.getCheckedEntries(), + ) + } + private fun Date.formatBackupDate(): String { return getString( R.string.backup_date_, @@ -105,59 +115,4 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis .show() dismiss() } - - private fun onProgressChanged(value: Float) { - with(requireViewBinding().progressBar) { - isVisible = true - val wasIndeterminate = isIndeterminate - isIndeterminate = value < 0 - if (value >= 0) { - setProgressCompat((value * max).roundToInt(), !wasIndeterminate) - } - } - } - - private fun onRestoreDone(result: CompositeResult) { - val builder = MaterialAlertDialogBuilder(context ?: return) - when { - result.isEmpty -> { - builder.setTitle(R.string.data_not_restored) - .setMessage(R.string.data_not_restored_text) - } - - result.isAllSuccess -> { - builder.setTitle(R.string.data_restored) - .setMessage(R.string.data_restored_success) - } - - result.isAllFailed -> builder.setTitle(R.string.error) - .setMessage( - result.failures.map { - it.getDisplayMessage(resources) - }.distinct().joinToString("\n"), - ) - - else -> builder.setTitle(R.string.data_restored) - .setMessage(R.string.data_restored_with_errors) - } - builder.setPositiveButton(android.R.string.ok, null) - .show() - if (!result.isEmpty && !result.isAllFailed) { - WelcomeSheet.dismiss(parentFragmentManager) - } - dismiss() - } - - - companion object { - - const val ARG_FILE = "file" - private const val TAG = "RestoreDialogFragment" - - fun show(fm: FragmentManager, uri: Uri) { - RestoreDialogFragment().withArgs(1) { - putString(ARG_FILE, uri.toString()) - }.show(fm, TAG) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreService.kt new file mode 100644 index 000000000..d5a6733e8 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreService.kt @@ -0,0 +1,300 @@ +package org.koitharu.kotatsu.settings.backup + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ErrorReporterReceiver +import org.koitharu.kotatsu.core.backup.BackupEntry +import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.core.backup.BackupZipInput +import org.koitharu.kotatsu.core.backup.CompositeResult +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.ui.CoroutineIntentService +import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.getFileDisplayName +import org.koitharu.kotatsu.core.util.ext.powerManager +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toUriOrNull +import org.koitharu.kotatsu.core.util.ext.withPartialWakeLock +import org.koitharu.kotatsu.core.util.progress.Progress +import org.koitharu.kotatsu.parsers.util.mapToArray +import org.koitharu.kotatsu.parsers.util.nullIfEmpty +import java.io.File +import java.io.FileNotFoundException +import java.util.EnumSet +import javax.inject.Inject +import com.google.android.material.R as materialR + +@AndroidEntryPoint +class RestoreService : CoroutineIntentService() { + + @Inject + lateinit var repository: BackupRepository + + private lateinit var notificationManager: NotificationManagerCompat + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(applicationContext) + } + + override suspend fun IntentJobContext.processIntent(intent: Intent) { + startForeground(this) + val uri = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException() + val displayName = contentResolver.getFileDisplayName(uri) + val entries = intent.getIntArrayExtra(AppRouter.KEY_ENTRIES) + ?.mapTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { BackupEntry.Name.entries[it] } + if (entries.isNullOrEmpty()) { + throw IllegalArgumentException("No entries specified") + } + powerManager.withPartialWakeLock(TAG) { + val result = runInterruptible(Dispatchers.IO) { + val tempFile = File.createTempFile("backup_", ".tmp") + (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + BackupZipInput.from(tempFile) + }.use { backupInput -> + restoreImpl(displayName, backupInput, entries) + } + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val notification = buildNotification(displayName, result) + notificationManager.notify(TAG, startId, notification) + } + } + } + + override fun IntentJobContext.onError(error: Throwable) { + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val result = CompositeResult() + result += error + val notification = buildNotification(null, result) + notificationManager.notify(TAG, startId, notification) + } + } + + private suspend fun IntentJobContext.restoreImpl( + displayName: String?, + input: BackupZipInput, + entries: Set + ): CompositeResult { + val result = CompositeResult() + val showNotification = applicationContext.checkNotificationPermission(CHANNEL_ID) + var progress = Progress(0, entries.size) + + fun notify(childProgress: Progress? = null) { + if (showNotification) { + val p = childProgress?.let { progress + it } ?: progress + notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(displayName, p)) + } + } + + notify() + + if (BackupEntry.Name.HISTORY in entries) { + input.getEntry(BackupEntry.Name.HISTORY)?.let { + flow { + result += repository.restoreHistory(it, this) + }.collect { p -> + notify(p) + } + } + progress++ + } + + notify() + + if (BackupEntry.Name.CATEGORIES in entries) { + input.getEntry(BackupEntry.Name.CATEGORIES)?.let { + result += repository.restoreCategories(it) + } + progress++ + } + + notify() + + if (BackupEntry.Name.FAVOURITES in entries) { + input.getEntry(BackupEntry.Name.FAVOURITES)?.let { + flow { + result += repository.restoreFavourites(it, this) + }.collect { p -> + notify(p) + } + } + } + + notify() + + if (BackupEntry.Name.BOOKMARKS in entries) { + input.getEntry(BackupEntry.Name.BOOKMARKS)?.let { + result += repository.restoreBookmarks(it) + } + progress++ + } + + notify() + + if (BackupEntry.Name.SOURCES in entries) { + input.getEntry(BackupEntry.Name.SOURCES)?.let { + result += repository.restoreSources(it) + } + progress++ + } + + notify() + + if (BackupEntry.Name.SETTINGS in entries) { + input.getEntry(BackupEntry.Name.SETTINGS)?.let { + result += repository.restoreSettings(it) + } + progress++ + } + + notify() + + if (BackupEntry.Name.SETTINGS_READER_GRID in entries) { + input.getEntry(BackupEntry.Name.SETTINGS_READER_GRID)?.let { + result += repository.restoreReaderGridSettings(it) + } + progress++ + } + + notify() + + return result + } + + @SuppressLint("InlinedApi") + private fun startForeground(jobContext: IntentJobContext) { + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(getString(R.string.restoring_backup)) + .setShowBadge(true) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(false) + .build() + notificationManager.createNotificationChannel(channel) + + val notification = jobContext.buildNotification(null, null) + + jobContext.setForeground( + FOREGROUND_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private fun IntentJobContext.buildNotification(fileName: String?, progress: Progress?): Notification { + return NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle(getString(R.string.restoring_backup)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setDefaults(0) + .setSilent(true) + .setOngoing(true) + .setProgress(progress?.total ?: 0, progress?.progress ?: 0, progress == null) + .setContentText( + concatStrings( + context = this@RestoreService, + a = fileName, + b = progress?.run { getString(R.string.percent_string_pattern, percentSting()) }, + ), + ) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .addAction( + materialR.drawable.material_ic_clear_black_24dp, + applicationContext.getString(android.R.string.cancel), + getCancelIntent(), + ).build() + } + + private fun buildNotification(fileName: String?, result: CompositeResult): Notification { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDefaults(0) + .setSilent(true) + .setAutoCancel(true) + .setSubText(fileName) + + when { + result.isEmpty -> notification.setContentTitle(getString(R.string.data_not_restored)) + .setContentText(getString(R.string.data_not_restored_text)) + .setSmallIcon(android.R.drawable.stat_notify_error) + + result.isAllSuccess -> notification.setContentTitle(getString(R.string.data_restored)) + .setContentText(getString(R.string.data_restored_success)) + .setSmallIcon(R.drawable.ic_stat_done) + + result.isAllFailed -> notification.setContentTitle(getString(R.string.error)) + .setContentText( + result.failures.map { it.getDisplayMessage(resources) }.distinct().joinToString("\n"), + ) + .setSmallIcon(android.R.drawable.stat_notify_error) + + else -> notification.setContentTitle(getString(R.string.data_restored)) + .setContentText(getString(R.string.data_restored_with_errors)) + .setSmallIcon(R.drawable.ic_stat_done) + } + result.failures.firstOrNull()?.let { error -> + ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent -> + notification.addAction( + R.drawable.ic_alert_outline, + applicationContext.getString(R.string.report), + reportIntent, + ) + } + } + notification.setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + 0, + AppRouter.homeIntent(this), + 0, + false, + ), + ) + return notification.build() + } + + private fun concatStrings(context: Context, a: String?, b: String?): String? = when { + a.isNullOrEmpty() && b.isNullOrEmpty() -> null + a.isNullOrEmpty() -> b?.nullIfEmpty() + b.isNullOrEmpty() -> a.nullIfEmpty() + else -> context.getString(R.string.download_summary_pattern, a, b) + } + + companion object { + + private const val TAG = "restore" + private const val CHANNEL_ID = "restore_backup" + private const val FOREGROUND_NOTIFICATION_ID = 39 + + fun start(context: Context, uri: Uri, entries: Set): Boolean = try { + val intent = Intent(context, RestoreService::class.java) + intent.putExtra(AppRouter.KEY_DATA, uri.toString()) + intent.putExtra(AppRouter.KEY_ENTRIES, entries.mapToArray { it.ordinal }.toIntArray()) + ContextCompat.startForegroundService(context, intent) + true + } catch (e: Exception) { + e.printStackTraceDebug() + false + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index 6aa77ded3..525602579 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -10,13 +10,9 @@ import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.backup.BackupEntry import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipInput -import org.koitharu.kotatsu.core.backup.CompositeResult +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toUriOrNull -import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import java.io.File import java.io.FileNotFoundException import java.util.Date @@ -31,30 +27,28 @@ class RestoreViewModel @Inject constructor( @ApplicationContext context: Context, ) : BaseViewModel() { - private val backupInput = suspendLazy { - val uri = savedStateHandle.get(RestoreDialogFragment.ARG_FILE) - ?.toUriOrNull() ?: throw FileNotFoundException() - val contentResolver = context.contentResolver + val uri = savedStateHandle.get(AppRouter.KEY_FILE)?.toUriOrNull() + private val contentResolver = context.contentResolver + + val availableEntries = MutableStateFlow>(emptyList()) + val backupDate = MutableStateFlow(null) + + init { + launchLoadingJob(Dispatchers.Default) { + loadBackupInfo() + } + } + + private suspend fun loadBackupInfo() { runInterruptible(Dispatchers.IO) { val tempFile = File.createTempFile("backup_", ".tmp") - (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> + (uri?.let { contentResolver.openInputStream(it) } ?: throw FileNotFoundException()).use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } BackupZipInput.from(tempFile) - } - } - - val progress = MutableStateFlow(-1f) - val onRestoreDone = MutableEventFlow() - - val availableEntries = MutableStateFlow>(emptyList()) - val backupDate = MutableStateFlow(null) - - init { - launchLoadingJob(Dispatchers.Default) { - val backup = backupInput.get() + }.use { backup -> val entries = backup.entries() availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry -> if (entry == BackupEntry.Name.INDEX || entry !in entries) { @@ -70,15 +64,6 @@ class RestoreViewModel @Inject constructor( } } - override fun onCleared() { - super.onCleared() - runCatching { - backupInput.peek()?.closeAndDelete() - }.onFailure { - it.printStackTraceDebug() - } - } - fun onItemClick(item: BackupEntryModel) { val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name } map[item.name] = item.copy(isChecked = !item.isChecked) @@ -86,61 +71,10 @@ class RestoreViewModel @Inject constructor( 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) - } - } - - progress.value += step - if (BackupEntry.Name.CATEGORIES in checkedItems) { - backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { - result += repository.restoreCategories(it) - } - } - - progress.value += step - if (BackupEntry.Name.FAVOURITES in checkedItems) { - backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { - result += repository.restoreFavourites(it) - } - } - - progress.value += step - if (BackupEntry.Name.BOOKMARKS in checkedItems) { - backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { - result += repository.restoreBookmarks(it) - } - } - - progress.value += step - 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) - } - } - - progress.value = 1f - onRestoreDone.call(result) + fun getCheckedEntries(): Set = availableEntries.value + .mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { + if (it.isChecked) it.name else null } - } /** * Check for inconsistent user selection diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigFragment.kt index 0d470973d..369376ed0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigFragment.kt @@ -5,8 +5,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView @@ -19,7 +18,12 @@ import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.dialog.setRecyclerViewList import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets +import org.koitharu.kotatsu.core.util.ext.container +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.start +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.model.ListModel @@ -34,8 +38,8 @@ class NavConfigFragment : BaseFragment(), Recycl private var reorderHelper: ItemTouchHelper? = null private val viewModel by viewModels() - override val recyclerView: RecyclerView - get() = requireViewBinding().recyclerView + override val recyclerView: RecyclerView? + get() = viewBinding?.recyclerView override fun onCreateViewBinding( inflater: LayoutInflater, @@ -62,6 +66,19 @@ class NavConfigFragment : BaseFragment(), Recycl viewModel.content.observe(viewLifecycleOwner, navConfigAdapter) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + val isTablet = !resources.getBoolean(R.bool.is_tablet) + val isMaster = container?.id == R.id.container_master + v.setPaddingRelative( + if (isTablet && !isMaster) 0 else barsInsets.start(v), + 0, + if (isTablet && isMaster) 0 else barsInsets.end(v), + barsInsets.bottom, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onResume() { super.onResume() activity?.setTitle(R.string.main_screen_sections) @@ -72,14 +89,6 @@ class NavConfigFragment : BaseFragment(), Recycl super.onDestroyView() } - override fun onWindowInsetsChanged(insets: Insets) { - requireViewBinding().recyclerView.updatePadding( - bottom = insets.bottom, - left = insets.left, - right = insets.right, - ) - } - override fun onClick(v: View) { var dialog: DialogInterface? = null val listener = OnListItemClickListener { item, _ -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt index 5b6883a6c..6a10558b8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt @@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.main.ui.MainActivity +import org.koitharu.kotatsu.main.ui.MainNavigationDelegate import org.koitharu.kotatsu.parsers.util.move import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel import org.koitharu.kotatsu.settings.nav.model.NavItemConfigModel @@ -38,7 +39,7 @@ class NavConfigViewModel @Inject constructor( NavItemConfigModel(it, getUnavailabilityHint(it)) } if (size < NavItem.entries.size) { - add(NavItemAddModel(size < 5)) + add(NavItemAddModel(size < MainNavigationDelegate.MAX_ITEM_COUNT)) } } }.stateIn( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt index cc086c8cd..e7ca825e0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt @@ -4,7 +4,6 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.text.Editable -import android.text.TextWatcher import android.view.KeyEvent import android.view.View import android.view.WindowManager @@ -12,14 +11,17 @@ import android.view.inputmethod.EditorInfo import android.widget.CompoundButton import android.widget.TextView import androidx.activity.viewModels -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding private const val MIN_PASSWORD_LENGTH = 4 @@ -27,7 +29,7 @@ private const val MIN_PASSWORD_LENGTH = 4 @AndroidEntryPoint class ProtectSetupActivity : BaseActivity(), - TextWatcher, + DefaultTextWatcher, View.OnClickListener, TextView.OnEditorActionListener, CompoundButton.OnCheckedChangeListener { @@ -58,14 +60,16 @@ class ProtectSetupActivity : } } - override fun onWindowInsetsChanged(insets: Insets) { + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) viewBinding.root.setPadding( - basePadding + insets.left, - basePadding + insets.top, - basePadding + insets.right, - basePadding + insets.bottom, + barsInsets.left + basePadding, + barsInsets.top + basePadding, + barsInsets.right + basePadding, + barsInsets.bottom + basePadding, ) + return insets.consumeAllSystemBarsInsets() } override fun onClick(v: View) { @@ -90,10 +94,6 @@ class ProtectSetupActivity : } } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - override fun afterTextChanged(s: Editable?) { viewBinding.editPassword.error = null val isEnoughLength = (s?.length ?: 0) >= MIN_PASSWORD_LENGTH diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/reader/ReaderTapGridConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/reader/ReaderTapGridConfigActivity.kt index d0894370a..75f13524d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/reader/ReaderTapGridConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/reader/ReaderTapGridConfigActivity.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.settings.reader import android.content.DialogInterface -import android.graphics.drawable.ColorDrawable import android.graphics.drawable.Drawable import android.graphics.drawable.LayerDrawable import android.os.Bundle @@ -11,17 +10,19 @@ import android.view.View import android.widget.TextView import androidx.activity.viewModels import androidx.core.graphics.ColorUtils -import androidx.core.graphics.Insets +import androidx.core.graphics.drawable.toDrawable import androidx.core.text.bold import androidx.core.text.buildSpannedString -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.findKeyByValue import org.koitharu.kotatsu.core.util.ext.getThemeDrawable import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityReaderTapActionsBinding import org.koitharu.kotatsu.reader.domain.TapGridArea import org.koitharu.kotatsu.reader.ui.tapgrid.TapAction @@ -39,7 +40,7 @@ class ReaderTapGridConfigActivity : BaseActivity(), viewModel.content.observe(viewLifecycleOwner, adapter) } - override fun onWindowInsetsChanged(insets: Insets) { - val extraPadding = resources.getDimensionPixelOffset(R.dimen.list_spacing) - requireViewBinding().root.updatePadding( - top = extraPadding, - right = insets.right, - left = insets.left, - bottom = insets.bottom, + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val type = WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(type) + v.setPadding( + barsInsets.left, + 0, + barsInsets.right, + barsInsets.bottom, ) + return insets.consumeAll(type) } override fun onItemClick(item: SettingsItem, view: View) = viewModel.navigateToPreference(item) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt index d0411d663..41441a77e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt @@ -10,6 +10,7 @@ import androidx.preference.get import dagger.Reusable import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.settings.AppearanceSettingsFragment import org.koitharu.kotatsu.settings.DownloadsSettingsFragment import org.koitharu.kotatsu.settings.NetworkSettingsFragment @@ -22,12 +23,13 @@ import org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment +import org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment import javax.inject.Inject @Reusable @SuppressLint("RestrictedApi") class SettingsSearchHelper @Inject constructor( - @ApplicationContext private val context: Context, + @LocalizedAppContext private val context: Context, ) { fun inflatePreferences(): List { @@ -38,6 +40,12 @@ class SettingsSearchHelper @Inject constructor( preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_network, emptyList(), NetworkSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_user_data, emptyList(), UserDataSettingsFragment::class.java) + preferenceManager.inflateTo( + result, + R.xml.pref_storage, + listOf(context.getString(R.string.data_and_privacy)), + StorageManageSettingsFragment::class.java, + ) preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt index 88d62e1b5..f183b971e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt @@ -7,9 +7,9 @@ import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.parser.EmptyMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings @@ -20,7 +20,6 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import java.io.File @AndroidEntryPoint @@ -43,7 +42,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc val isValidSource = viewModel.repository !is EmptyMangaRepository findPreference(KEY_ENABLE)?.run { - isVisible = isValidSource + isVisible = isValidSource && !settings.isAllSourcesEnabled onPreferenceChangeListener = this@SourceSettingsFragment } findPreference(KEY_AUTH)?.run { @@ -87,18 +86,15 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { KEY_AUTH -> { - startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source)) + router.openSourceAuth(viewModel.source) true } AppSettings.KEY_OPEN_BROWSER -> { - startActivity( - BrowserActivity.newIntent( - context = preference.context, - url = viewModel.browserUrl.value ?: return false, - source = viewModel.source, - title = viewModel.source.getTitle(preference.context), - ), + router.openBrowser( + url = viewModel.browserUrl.value ?: return false, + source = viewModel.source, + title = viewModel.source.getTitle(preference.context), ) true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt index 49ed190bc..f59ca7015 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt @@ -1,6 +1,6 @@ package org.koitharu.kotatsu.settings.sources -import android.content.Intent +import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.fragment.app.viewModels @@ -9,16 +9,18 @@ import androidx.preference.Preference import androidx.preference.TwoStatePreference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity @AndroidEntryPoint -class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) { +class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources), + SharedPreferences.OnSharedPreferenceChangeListener { private val viewModel by viewModels() @@ -36,7 +38,7 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) findPreference(AppSettings.KEY_REMOTE_SOURCES)?.let { pref -> viewModel.enabledSourcesCount.observe(viewLifecycleOwner) { pref.summary = if (it >= 0) { - resources.getQuantityString(R.plurals.items, it, it) + resources.getQuantityStringSafe(R.plurals.items, it, it) } else { null } @@ -44,10 +46,10 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) } findPreference(AppSettings.KEY_SOURCES_CATALOG)?.let { pref -> viewModel.availableSourcesCount.observe(viewLifecycleOwner) { - pref.summary = if (it >= 0) { - getString(R.string.available_d, it) - } else { - null + pref.summary = when { + it == 0 -> getString(R.string.all_sources_enabled) + it > 0 -> getString(R.string.available_d, it) + else -> null } } } @@ -56,11 +58,18 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) pref.isChecked = it } } + updateEnableAllDependencies() + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() } override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { AppSettings.KEY_SOURCES_CATALOG -> { - startActivity(Intent(preference.context, SourcesCatalogActivity::class.java)) + router.openSourcesCatalog() true } @@ -71,4 +80,14 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) else -> super.onPreferenceTreeClick(preference) } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_SOURCES_ENABLED_ALL -> updateEnableAllDependencies() + } + } + + private fun updateEnableAllDependencies() { + findPreference(AppSettings.KEY_SOURCES_CATALOG)?.isEnabled = !settings.isAllSourcesEnabled + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt index 9d705714d..d114b3036 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -58,7 +58,7 @@ fun sourceConfigItemDelegate2( bind { binding.textViewTitle.text = item.source.getTitle(context) binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable - binding.imageViewRemove.isVisible = item.isEnabled + binding.imageViewRemove.isVisible = item.isEnabled && item.isDisableAvailable binding.imageViewMenu.isVisible = item.isEnabled binding.textViewTitle.drawableStart = if (item.isPinned) iconPinned else null binding.textViewDescription.text = item.source.getSummary(context) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt index ea285a8a4..d491585ed 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt @@ -1,46 +1,41 @@ package org.koitharu.kotatsu.settings.sources.auth -import android.annotation.SuppressLint -import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle import android.view.MenuItem -import android.webkit.CookieManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContract -import androidx.core.graphics.Insets -import androidx.core.view.isVisible -import androidx.core.view.updatePadding +import androidx.lifecycle.lifecycleScope +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.launch import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.browser.BaseBrowserActivity import org.koitharu.kotatsu.browser.BrowserCallback import org.koitharu.kotatsu.browser.BrowserClient -import org.koitharu.kotatsu.browser.ProgressChromeClient -import org.koitharu.kotatsu.browser.WebViewBackPressedCallback import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository -import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.configureForParser +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment.Companion.EXTRA_SOURCE import javax.inject.Inject -import com.google.android.material.R as materialR @AndroidEntryPoint -class SourceAuthActivity : BaseActivity(), BrowserCallback { +class SourceAuthActivity : BaseBrowserActivity(), BrowserCallback { @Inject lateinit var mangaRepositoryFactory: MangaRepository.Factory - private lateinit var onBackPressedCallback: WebViewBackPressedCallback private lateinit var authProvider: MangaParserAuthProvider - @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) if (!setContentViewWebViewSafe { ActivityBrowserBinding.inflate(layoutInflater) }) { @@ -61,36 +56,30 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba finishAfterTransition() return } - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } + setDisplayHomeAsUp(true, true) viewBinding.webView.configureForParser(repository.getRequestHeaders()[CommonHeaders.USER_AGENT]) - CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) - viewBinding.webView.webViewClient = BrowserClient(this) - viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) - onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) - onBackPressedDispatcher.addCallback(onBackPressedCallback) - if (savedInstanceState != null) { - return + viewBinding.webView.webViewClient = BrowserClient(proxyProvider, this) + lifecycleScope.launch { + try { + proxyProvider.applyWebViewConfig() + } catch (e: Exception) { + Snackbar.make(viewBinding.webView, e.getDisplayMessage(resources), Snackbar.LENGTH_LONG).show() + } + if (savedInstanceState == null) { + val url = authProvider.authUrl + onTitleChanged( + source.title, + getString(R.string.loading_), + ) + viewBinding.webView.loadUrl(url) + } } - val url = authProvider.authUrl - onTitleChanged( - source.title, - getString(R.string.loading_), - ) - viewBinding.webView.loadUrl(url) - } - - override fun onDestroy() { - super.onDestroy() - viewBinding.webView.destroy() } override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { viewBinding.webView.stopLoading() - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) finishAfterTransition() true } @@ -98,42 +87,18 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba else -> super.onOptionsItemSelected(item) } - override fun onPause() { - viewBinding.webView.onPause() - super.onPause() - } - - override fun onResume() { - super.onResume() - viewBinding.webView.onResume() - } - override fun onLoadingStateChanged(isLoading: Boolean) { - viewBinding.progressBar.isVisible = isLoading + super.onLoadingStateChanged(isLoading) if (!isLoading && authProvider.isAuthorized) { Toast.makeText(this, R.string.auth_complete, Toast.LENGTH_SHORT).show() - setResult(Activity.RESULT_OK) + setResult(RESULT_OK) finishAfterTransition() } } - override fun onTitleChanged(title: CharSequence, subtitle: CharSequence?) { - this.title = title - supportActionBar?.subtitle = subtitle - } - - override fun onHistoryChanged() { - onBackPressedCallback.onHistoryChanged() - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.appbar.updatePadding(top = insets.top) - viewBinding.webView.updatePadding(bottom = insets.bottom) - } - class Contract : ActivityResultContract() { override fun createIntent(context: Context, input: MangaSource): Intent { - return newIntent(context, input) + return AppRouter.sourceAuthIntent(context, input) } override fun parseResult(resultCode: Int, intent: Intent?): Boolean { @@ -142,13 +107,6 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba } companion object { - - private const val EXTRA_SOURCE = "source" const val TAG = "SourceAuthActivity" - - fun newIntent(context: Context, source: MangaSource): Intent { - return Intent(context, SourceAuthActivity::class.java) - .putExtra(EXTRA_SOURCE, source.name) - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt index bb4b71200..b05e569c5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt @@ -8,6 +8,7 @@ import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.appcompat.widget.SearchView import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import coil3.ImageLoader import com.google.android.material.appbar.AppBarLayout @@ -16,6 +17,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.FadingAppbarMediator @@ -31,13 +33,14 @@ import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.search.ui.MangaListActivity import javax.inject.Inject @AndroidEntryPoint class SourcesCatalogActivity : BaseActivity(), OnListItemClickListener, - AppBarOwner, MenuItem.OnActionExpandListener, ChipsView.OnChipClickListener { + AppBarOwner, + MenuItem.OnActionExpandListener, + ChipsView.OnChipClickListener { @Inject lateinit var coil: ImageLoader @@ -50,7 +53,7 @@ class SourcesCatalogActivity : BaseActivity(), override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivitySourcesCatalogBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUp(true, false) val sourcesAdapter = SourcesCatalogAdapter(this, coil, this) with(viewBinding.recyclerView) { setHasFixedSize(true) @@ -70,14 +73,21 @@ class SourcesCatalogActivity : BaseActivity(), addMenuProvider(SourcesCatalogMenuProvider(this, viewModel, this)) } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) viewBinding.recyclerView.updatePadding( - bottom = insets.bottom, + left = bars.left, + right = bars.right, + bottom = bars.bottom, + ) + viewBinding.appbar.updatePadding( + left = bars.left, + right = bars.right, + top = bars.top, ) + return return WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) + .build() } override fun onChipClick(chip: Chip, data: Any?) { @@ -89,7 +99,7 @@ class SourcesCatalogActivity : BaseActivity(), } override fun onItemClick(item: SourceCatalogItem.Source, view: View) { - startActivity(MangaListActivity.newIntent(this, item.source, null)) + router.openList(item.source, null, null) } override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt index 7df5129f6..2901f6bc1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt @@ -15,6 +15,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.LocalizedAppContext import org.koitharu.kotatsu.core.db.TABLE_SOURCES import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw @@ -31,7 +32,7 @@ import javax.inject.Inject @ViewModelScoped class SourcesListProducer @Inject constructor( lifecycle: ViewModelLifecycle, - @ApplicationContext private val context: Context, + @LocalizedAppContext private val context: Context, private val repository: MangaSourcesRepository, private val settings: AppSettings, ) : InvalidationTracker.Observer(TABLE_SOURCES) { @@ -70,6 +71,7 @@ class SourcesListProducer @Inject constructor( val pinned = repository.getPinnedSources().mapToSet { it.name } val isNsfwDisabled = settings.isNsfwContentDisabled val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL + val isDisableAvailable = !settings.isAllSourcesEnabled val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER) val enabledSet = enabledSources.toSet() if (query.isNotEmpty()) { @@ -83,6 +85,7 @@ class SourcesListProducer @Inject constructor( isDraggable = false, isAvailable = !isNsfwDisabled || !it.isNsfw(), isPinned = it.name in pinned, + isDisableAvailable = isDisableAvailable, ) }.ifEmpty { listOf(SourceConfigItem.EmptySearchResult) @@ -104,6 +107,7 @@ class SourcesListProducer @Inject constructor( isDraggable = isReorderAvailable, isAvailable = false, isPinned = it.name in pinned, + isDisableAvailable = isDisableAvailable, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt index 07bcc8921..54cb47dd7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt @@ -1,16 +1,15 @@ package org.koitharu.kotatsu.settings.sources.manage -import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.Menu import android.view.MenuInflater import android.view.MenuItem +import android.view.View import android.view.ViewGroup import androidx.appcompat.widget.SearchView -import androidx.core.graphics.Insets import androidx.core.view.MenuProvider -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.ItemTouchHelper import androidx.recyclerview.widget.RecyclerView @@ -18,15 +17,21 @@ import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets +import org.koitharu.kotatsu.core.util.ext.container +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.getItem import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.start +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.databinding.FragmentSettingsSourcesBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner @@ -34,7 +39,6 @@ import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener -import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import javax.inject.Inject @@ -57,8 +61,8 @@ class SourcesManageFragment : private var sourcesAdapter: SourceConfigAdapter? = null private val viewModel by viewModels() - override val recyclerView: RecyclerView - get() = requireViewBinding().recyclerView + override val recyclerView: RecyclerView? + get() = viewBinding?.recyclerView override fun onCreateViewBinding( inflater: LayoutInflater, @@ -86,6 +90,19 @@ class SourcesManageFragment : addMenuProvider(SourcesMenuProvider()) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + val isTablet = !resources.getBoolean(R.bool.is_tablet) + val isMaster = container?.id == R.id.container_master + v.setPaddingRelative( + if (isTablet && !isMaster) 0 else barsInsets.start(v), + 0, + if (isTablet && isMaster) 0 else barsInsets.end(v), + barsInsets.bottom, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onResume() { super.onResume() activity?.setTitle(R.string.manage_sources) @@ -97,14 +114,6 @@ class SourcesManageFragment : super.onDestroyView() } - override fun onWindowInsetsChanged(insets: Insets) { - requireViewBinding().recyclerView.updatePadding( - bottom = insets.bottom, - left = insets.left, - right = insets.right, - ) - } - override fun onItemSettingsClick(item: SourceConfigItem.SourceItem) { (activity as? SettingsActivity)?.openFragment( fragmentClass = SourceSettingsFragment::class.java, @@ -152,7 +161,7 @@ class SourcesManageFragment : override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_catalog -> { - startActivity(Intent(context, SourcesCatalogActivity::class.java)) + router.openSourcesCatalog() true } @@ -172,6 +181,8 @@ class SourcesManageFragment : override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_no_nsfw).isChecked = settings.isNsfwContentDisabled + menu.findItem(R.id.action_disable_all).isVisible = !settings.isAllSourcesEnabled + menu.findItem(R.id.action_catalog).isVisible = !settings.isAllSourcesEnabled } override fun onMenuItemActionExpand(item: MenuItem): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt index 321a54334..2c014f07e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt @@ -14,6 +14,7 @@ sealed interface SourceConfigItem : ListModel { val isDraggable: Boolean, val isAvailable: Boolean, val isPinned: Boolean, + val isDisableAvailable: Boolean, ) : SourceConfigItem { val isNsfw: Boolean diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt index f9294c3e0..e7fd70bbf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.settings.storage import android.Manifest +import android.content.Intent import android.os.Build import android.os.Bundle import android.view.LayoutInflater @@ -8,18 +9,17 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ToastErrorObserver +import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.databinding.DialogDirectorySelectBinding @@ -28,7 +28,12 @@ class MangaDirectorySelectDialog : AlertDialogFragment { private val viewModel: MangaDirectorySelectViewModel by viewModels() - private val pickFileTreeLauncher = registerForActivityResult(PickDirectoryContract()) { + private val pickFileTreeLauncher = OpenDocumentTreeHelper( + activityResultCaller = this, + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION, + ) { if (it != null) viewModel.onCustomDirectoryPicked(it) } private val permissionRequestLauncher = registerForActivityResult( @@ -80,12 +85,4 @@ class MangaDirectorySelectDialog : AlertDialogFragment() OnListItemClickListener, View.OnClickListener { private val viewModel: MangaDirectoriesViewModel by viewModels() - private val pickFileTreeLauncher = registerForActivityResult(PickDirectoryContract()) { + private val pickFileTreeLauncher = OpenDocumentTreeHelper( + activityResultCaller = this, + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION, + ) { if (it != null) viewModel.onCustomDirectoryPicked(it) } private val permissionRequestLauncher = registerForActivityResult( @@ -57,7 +62,7 @@ class MangaDirectoriesActivity : BaseActivity() override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityMangaDirectoriesBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUp(true, false) val adapter = AsyncListDifferDelegationAdapter(DirectoryDiffCallback(), directoryConfigAD(this)) viewBinding.recyclerView.adapter = adapter viewBinding.fabAdd.setOnClickListener(this) @@ -83,23 +88,23 @@ class MangaDirectoriesActivity : BaseActivity() } } - override fun onWindowInsetsChanged(insets: Insets) { + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.getInsets(WindowInsetsCompat.Type.systemBars()) viewBinding.fabAdd.updateLayoutParams { - rightMargin = topMargin + insets.right - leftMargin = topMargin + insets.left - bottomMargin = topMargin + insets.bottom + rightMargin = topMargin + barsInsets.right + leftMargin = topMargin + barsInsets.left + bottomMargin = topMargin + barsInsets.bottom } - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, + viewBinding.appbar.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + top = barsInsets.top, ) viewBinding.recyclerView.updatePadding( - bottom = insets.bottom, + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, MangaDirectoriesActivity::class.java) + return insets.consumeAllSystemBarsInsets() } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt index d9d8834cb..a3d8bf8a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/directories/MangaDirectoriesViewModel.kt @@ -8,6 +8,8 @@ import kotlinx.coroutines.cancelAndJoin import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.util.ext.isReadable +import org.koitharu.kotatsu.core.util.ext.isWriteable import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.settings.storage.DirectoryModel import java.io.File @@ -69,7 +71,7 @@ class MangaDirectoriesViewModel @Inject constructor( titleRes = 0, file = dir, isChecked = dir == downloadDir, - isAvailable = dir.canRead() && dir.canWrite(), + isAvailable = dir.isReadable() && dir.isWriteable(), isRemovable = false, ) } @@ -79,7 +81,7 @@ class MangaDirectoriesViewModel @Inject constructor( titleRes = 0, file = dir, isChecked = dir == downloadDir, - isAvailable = dir.canRead() && dir.canWrite(), + isAvailable = dir.isReadable() && dir.isWriteable(), isRemovable = true, ) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt index cb0ccd202..08105be06 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.settings.tracker +import android.content.ActivityNotFoundException import android.content.Intent import android.content.SharedPreferences import android.net.Uri @@ -14,15 +15,16 @@ import androidx.fragment.app.viewModels import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet import org.koitharu.kotatsu.settings.utils.DozeHelper import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity @@ -108,7 +110,7 @@ class TrackerSettingsFragment : } AppSettings.KEY_TRACK_CATEGORIES -> { - TrackerCategoriesConfigSheet.show(childFragmentManager) + router.showTrackerCategoriesConfigSheet() true } @@ -147,4 +149,12 @@ class TrackerSettingsFragment : getString(R.string.enabled_d_of_d, count[0], count[1]) } } + + private fun startActivitySafe(intent: Intent): Boolean = try { + startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + false + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt index 3e0a07e34..00bdedcc5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt @@ -4,13 +4,15 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.FragmentManager +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet +import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.SheetBaseBinding @@ -34,14 +36,15 @@ class TrackerCategoriesConfigSheet : viewModel.content.observe(viewLifecycleOwner, adapter) } - override fun onItemClick(item: FavouriteCategory, view: View) { - viewModel.toggleItem(item) + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + viewBinding?.recyclerView?.updatePadding( + bottom = insets.getInsets(typeMask).bottom, + ) + return insets.consume(v, typeMask, bottom = true) } - companion object { - - private const val TAG = "TrackerCategoriesConfigSheet" - - fun show(fm: FragmentManager) = TrackerCategoriesConfigSheet().show(fm, TAG) + override fun onItemClick(item: FavouriteCategory, view: View) { + viewModel.toggleItem(item) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt index e85a1beee..2c9863100 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt @@ -7,39 +7,28 @@ import android.os.Bundle import android.view.View import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatDelegate -import androidx.collection.ArraySet -import androidx.core.view.postDelayed import androidx.fragment.app.viewModels import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import androidx.preference.TwoStatePreference -import androidx.preference.forEach -import androidx.preference.get -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.core.prefs.SearchSuggestionType import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle -import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.tryLaunch -import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.backup.BackupDialogFragment -import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import javax.inject.Inject @@ -52,11 +41,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac @Inject lateinit var appShortcutManager: AppShortcutManager - @Inject - lateinit var activityRecreationHandle: ActivityRecreationHandle - private val viewModel: UserDataSettingsViewModel by viewModels() - private val loadingPrefs = HashSet() private val backupSelectCall = registerForActivityResult( ActivityResultContracts.OpenDocument(), @@ -77,46 +62,23 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) - findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) - findPreference(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) bindPeriodicalBackupSummary() - findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> - viewModel.searchHistoryCount.observe(viewLifecycleOwner) { - pref.summary = if (it < 0) { - view.context.getString(R.string.loading_) - } else { - pref.context.resources.getQuantityString(R.plurals.items, it, it) - } - } - } - findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> - viewModel.feedItemsCount.observe(viewLifecycleOwner) { - pref.summary = if (it < 0) { - view.context.getString(R.string.loading_) - } else { - pref.context.resources.getQuantityString(R.plurals.items, it, it) - } - } - } - findPreference("storage_usage")?.let { pref -> - viewModel.storageUsage.observe(viewLifecycleOwner, pref) - } findPreference(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref -> pref.entryValues = SearchSuggestionType.entries.names() pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray() pref.summaryProvider = MultiSummaryProvider(R.string.none) pref.values = settings.searchSuggestionTypes.mapToSet { it.name } } - viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> - loadingPrefs.addAll(keys) - loadingPrefs.forEach { prefKey -> - findPreference(prefKey)!!.isEnabled = prefKey !in keys + findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> + viewModel.storageUsage.observe(viewLifecycleOwner) { size -> + pref.summary = if (size < 0L) { + pref.context.getString(R.string.computing_) + } else { + FileSize.BYTES.format(pref.context, size) + } } } viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) - viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) - viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp) settings.subscribe(this) } @@ -127,43 +89,8 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { - AppSettings.KEY_PAGES_CACHE_CLEAR -> { - viewModel.clearCache(preference.key, CacheDir.PAGES) - true - } - - AppSettings.KEY_THUMBS_CACHE_CLEAR -> { - viewModel.clearCache(preference.key, CacheDir.THUMBS) - true - } - - AppSettings.KEY_COOKIES_CLEAR -> { - clearCookies() - true - } - - AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { - clearSearchHistory() - true - } - - AppSettings.KEY_HTTP_CACHE_CLEAR -> { - viewModel.clearHttpCache() - true - } - - AppSettings.KEY_CHAPTERS_CLEAR -> { - cleanupChapters() - true - } - - AppSettings.KEY_UPDATES_FEED_CLEAR -> { - viewModel.clearUpdatesFeed() - true - } - AppSettings.KEY_BACKUP -> { - BackupDialogFragment.show(childFragmentManager) + router.showBackupCreateDialog() true } @@ -197,50 +124,12 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac findPreference(AppSettings.KEY_PROTECT_APP) ?.isChecked = !settings.appPassword.isNullOrEmpty() } - - AppSettings.KEY_THEME -> { - AppCompatDelegate.setDefaultNightMode(settings.theme) - } - - AppSettings.KEY_COLOR_THEME, - AppSettings.KEY_THEME_AMOLED -> { - postRestart() - } - - AppSettings.KEY_APP_LOCALE -> { - AppCompatDelegate.setApplicationLocales(settings.appLocales) - } } } override fun onActivityResult(result: Uri?) { if (result != null) { - RestoreDialogFragment.show(childFragmentManager, result) - } - } - - private fun onChaptersCleanedUp(result: Pair) { - val c = context ?: return - val text = if (result.first == 0 && result.second == 0L) { - c.getString(R.string.no_chapters_deleted) - } else { - c.getString( - R.string.chapters_deleted_pattern, - c.resources.getQuantityString(R.plurals.chapters, result.first, result.first), - FileSize.BYTES.format(c, result.second), - ) - } - Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show() - } - - - private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow) { - stateFlow.observe(viewLifecycleOwner) { size -> - summary = if (size < 0) { - context.getString(R.string.computing_) - } else { - FileSize.BYTES.format(context, size) - } + router.showBackupRestoreDialog(result) } } @@ -257,41 +146,4 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac } } } - - private fun clearSearchHistory() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.clear_search_history) - .setMessage(R.string.text_clear_search_history_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.clear) { _, _ -> - viewModel.clearSearchHistory() - }.show() - } - - private fun clearCookies() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.clear_cookies) - .setMessage(R.string.text_clear_cookies_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.clear) { _, _ -> - viewModel.clearCookies() - }.show() - } - - private fun cleanupChapters() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.delete_read_chapters) - .setMessage(R.string.delete_read_chapters_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete) { _, _ -> - viewModel.cleanupChapters() - }.show() - } - - private fun postRestart() { - view?.postDelayed(400) { - activityRecreationHandle.recreateAll() - } - } - } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt index ed87944c0..75b57ad44 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt @@ -4,51 +4,22 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.runInterruptible -import okhttp3.Cache -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar 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.util.ReversibleAction -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.firstNotNull -import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase -import org.koitharu.kotatsu.search.domain.MangaSearchRepository -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import java.util.EnumMap import javax.inject.Inject @HiltViewModel class UserDataSettingsViewModel @Inject constructor( private val storageManager: LocalStorageManager, - private val httpCache: Cache, - private val searchRepository: MangaSearchRepository, - private val trackingRepository: TrackingRepository, - private val cookieJar: MutableCookieJar, private val settings: AppSettings, - private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, ) : BaseViewModel() { - val onActionDone = MutableEventFlow() - val loadingKeys = MutableStateFlow(emptySet()) - - val searchHistoryCount = MutableStateFlow(-1) - val feedItemsCount = MutableStateFlow(-1) - val httpCacheSize = MutableStateFlow(-1L) - val cacheSizes = EnumMap>(CacheDir::class.java) - val storageUsage = MutableStateFlow(null) - - val onChaptersCleanedUp = MutableEventFlow>() + val storageUsage = MutableStateFlow(-1L) val periodicalBackupFrequency = settings.observeAsFlow( key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, @@ -67,120 +38,15 @@ class UserDataSettingsViewModel @Inject constructor( private var storageUsageJob: Job? = null init { - CacheDir.entries.forEach { - cacheSizes[it] = MutableStateFlow(-1L) - } - launchJob(Dispatchers.Default) { - searchHistoryCount.value = searchRepository.getSearchHistoryCount() - } - launchJob(Dispatchers.Default) { - feedItemsCount.value = trackingRepository.getLogsCount() - } - CacheDir.entries.forEach { cache -> - launchJob(Dispatchers.Default) { - checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) - } - } - launchJob(Dispatchers.Default) { - httpCacheSize.value = runInterruptible { httpCache.size() } - } loadStorageUsage() } - fun clearCache(key: String, cache: CacheDir) { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + key } - storageManager.clearCache(cache) - checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) - loadStorageUsage() - } finally { - loadingKeys.update { it - key } - } - } - } - - fun clearHttpCache() { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR } - val size = runInterruptible(Dispatchers.IO) { - httpCache.evictAll() - httpCache.size() - } - httpCacheSize.value = size - loadStorageUsage() - } finally { - loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR } - } - } - } - - fun clearSearchHistory() { - launchJob(Dispatchers.Default) { - searchRepository.clearSearchHistory() - searchHistoryCount.value = searchRepository.getSearchHistoryCount() - onActionDone.call(ReversibleAction(R.string.search_history_cleared, null)) - } - } - - fun clearCookies() { - launchJob { - cookieJar.clear() - onActionDone.call(ReversibleAction(R.string.cookies_cleared, null)) - } - } - - fun clearUpdatesFeed() { - launchJob(Dispatchers.Default) { - trackingRepository.clearLogs() - feedItemsCount.value = trackingRepository.getLogsCount() - onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) - } - } - - fun cleanupChapters() { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR } - val oldSize = storageUsage.firstNotNull().savedManga.bytes - val chaptersCount = deleteReadChaptersUseCase.invoke() - loadStorageUsage().join() - val newSize = storageUsage.firstNotNull().savedManga.bytes - onChaptersCleanedUp.call(chaptersCount to oldSize - newSize) - } finally { - loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR } - } - } - } - private fun loadStorageUsage(): Job { val prevJob = storageUsageJob return launchJob(Dispatchers.Default) { prevJob?.cancelAndJoin() - val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES) - val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize - val storageSize = storageManager.computeStorageSize() - val availableSpace = storageManager.computeAvailableSize() - val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace - storageUsage.value = StorageUsage( - savedManga = StorageUsage.Item( - bytes = storageSize, - percent = (storageSize.toDouble() / totalBytes).toFloat(), - ), - pagesCache = StorageUsage.Item( - bytes = pagesCacheSize, - percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(), - ), - otherCache = StorageUsage.Item( - bytes = otherCacheSize, - percent = (otherCacheSize.toDouble() / totalBytes).toFloat(), - ), - available = StorageUsage.Item( - bytes = availableSpace, - percent = (availableSpace.toDouble() / totalBytes).toFloat(), - ), - ) + val totalBytes = storageManager.computeCacheSize() + storageManager.computeStorageSize() + storageUsage.value = totalBytes }.also { storageUsageJob = it } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt new file mode 100644 index 000000000..6b29bb9a5 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt @@ -0,0 +1,173 @@ +package org.koitharu.kotatsu.settings.userdata.storage + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.preference.Preference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver +import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.local.data.CacheDir + +@AndroidEntryPoint +class StorageManageSettingsFragment : BasePreferenceFragment(R.string.storage_usage) { + + private val viewModel by viewModels() + private val loadingPrefs = HashSet() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_storage) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) + findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) + findPreference(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) + findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> + viewModel.searchHistoryCount.observe(viewLifecycleOwner) { + pref.summary = if (it < 0) { + view.context.getString(R.string.loading_) + } else { + pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it) + } + } + } + findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> + viewModel.feedItemsCount.observe(viewLifecycleOwner) { + pref.summary = if (it < 0) { + view.context.getString(R.string.loading_) + } else { + pref.context.resources.getQuantityStringSafe(R.plurals.items, it, it) + } + } + } + findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> + viewModel.storageUsage.observe(viewLifecycleOwner, pref) + } + findPreference(AppSettings.KEY_WEBVIEW_CLEAR)?.isVisible = viewModel.isBrowserDataCleanupEnabled + + viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> + loadingPrefs.addAll(keys) + loadingPrefs.forEach { prefKey -> + findPreference(prefKey)?.isEnabled = prefKey !in keys + } + } + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) + viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) + viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { + AppSettings.KEY_COOKIES_CLEAR -> { + clearCookies() + true + } + + AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { + clearSearchHistory() + true + } + + AppSettings.KEY_PAGES_CACHE_CLEAR -> { + viewModel.clearCache(preference.key, CacheDir.PAGES) + true + } + + AppSettings.KEY_THUMBS_CACHE_CLEAR -> { + viewModel.clearCache(preference.key, CacheDir.THUMBS) + true + } + + AppSettings.KEY_HTTP_CACHE_CLEAR -> { + viewModel.clearHttpCache() + true + } + + AppSettings.KEY_CHAPTERS_CLEAR -> { + cleanupChapters() + true + } + + AppSettings.KEY_WEBVIEW_CLEAR -> { + viewModel.clearBrowserData() + true + } + + AppSettings.KEY_CLEAR_MANGA_DATA -> { + viewModel.clearMangaData() + true + } + + AppSettings.KEY_UPDATES_FEED_CLEAR -> { + viewModel.clearUpdatesFeed() + true + } + + else -> super.onPreferenceTreeClick(preference) + } + + private fun onChaptersCleanedUp(result: Pair) { + val c = context ?: return + val text = if (result.first == 0 && result.second == 0L) { + c.getString(R.string.no_chapters_deleted) + } else { + c.getString( + R.string.chapters_deleted_pattern, + c.resources.getQuantityStringSafe(R.plurals.chapters, result.first, result.first), + FileSize.BYTES.format(c, result.second), + ) + } + Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show() + } + + private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow) { + stateFlow.observe(viewLifecycleOwner) { size -> + summary = if (size < 0) { + context.getString(R.string.computing_) + } else { + FileSize.BYTES.format(context, size) + } + } + } + + private fun clearSearchHistory() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.clear_search_history) + .setMessage(R.string.text_clear_search_history_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearSearchHistory() + }.show() + } + + private fun clearCookies() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.clear_cookies) + .setMessage(R.string.text_clear_cookies_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearCookies() + }.show() + } + + private fun cleanupChapters() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.delete_read_chapters) + .setMessage(R.string.delete_read_chapters_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.delete) { _, _ -> + viewModel.cleanupChapters() + }.show() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt new file mode 100644 index 000000000..c57be32ee --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt @@ -0,0 +1,224 @@ +package org.koitharu.kotatsu.settings.userdata.storage + +import android.annotation.SuppressLint +import android.webkit.WebStorage +import androidx.webkit.WebStorageCompat +import androidx.webkit.WebViewFeature +import coil3.ImageLoader +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runInterruptible +import okhttp3.Cache +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.util.ReversibleAction +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.firstNotNull +import org.koitharu.kotatsu.local.data.CacheDir +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase +import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import java.util.EnumMap +import javax.inject.Inject +import javax.inject.Provider +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +@HiltViewModel +class StorageManageSettingsViewModel @Inject constructor( + private val storageManager: LocalStorageManager, + private val httpCache: Cache, + private val searchRepository: MangaSearchRepository, + private val trackingRepository: TrackingRepository, + private val cookieJar: MutableCookieJar, + private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, + private val mangaDataRepositoryProvider: Provider, + private val coil: ImageLoader, +) : BaseViewModel() { + + val onActionDone = MutableEventFlow() + val loadingKeys = MutableStateFlow(emptySet()) + + val searchHistoryCount = MutableStateFlow(-1) + val feedItemsCount = MutableStateFlow(-1) + val httpCacheSize = MutableStateFlow(-1L) + val cacheSizes = EnumMap>(CacheDir::class.java) + val storageUsage = MutableStateFlow(null) + + val onChaptersCleanedUp = MutableEventFlow>() + + val isBrowserDataCleanupEnabled: Boolean + get() = WebViewFeature.isFeatureSupported(WebViewFeature.DELETE_BROWSING_DATA) + + private var storageUsageJob: Job? = null + + init { + CacheDir.entries.forEach { + cacheSizes[it] = MutableStateFlow(-1L) + } + launchJob(Dispatchers.Default) { + searchHistoryCount.value = searchRepository.getSearchHistoryCount() + } + launchJob(Dispatchers.Default) { + feedItemsCount.value = trackingRepository.getLogsCount() + } + CacheDir.entries.forEach { cache -> + launchJob(Dispatchers.Default) { + checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) + } + } + launchJob(Dispatchers.Default) { + httpCacheSize.value = runInterruptible { httpCache.size() } + } + loadStorageUsage() + } + + fun clearCache(key: String, cache: CacheDir) { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + key } + storageManager.clearCache(cache) + checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) + loadStorageUsage() + if (cache == CacheDir.THUMBS || cache == CacheDir.FAVICONS) { + coil.memoryCache?.clear() + } + } finally { + loadingKeys.update { it - key } + } + } + } + + fun clearHttpCache() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR } + val size = runInterruptible(Dispatchers.IO) { + httpCache.evictAll() + httpCache.size() + } + httpCacheSize.value = size + loadStorageUsage() + } finally { + loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR } + } + } + } + + fun clearSearchHistory() { + launchJob(Dispatchers.Default) { + searchRepository.clearSearchHistory() + searchHistoryCount.value = searchRepository.getSearchHistoryCount() + onActionDone.call(ReversibleAction(R.string.search_history_cleared, null)) + } + } + + fun clearCookies() { + launchJob { + cookieJar.clear() + onActionDone.call(ReversibleAction(R.string.cookies_cleared, null)) + } + } + + @SuppressLint("RequiresFeature") + fun clearBrowserData() { + launchJob { + try { + loadingKeys.update { it + AppSettings.KEY_WEBVIEW_CLEAR } + val storage = WebStorage.getInstance() + suspendCoroutine { cont -> + WebStorageCompat.deleteBrowsingData(storage) { + cont.resume(Unit) + } + } + onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) + } finally { + loadingKeys.update { it - AppSettings.KEY_WEBVIEW_CLEAR } + } + } + } + + fun clearUpdatesFeed() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_UPDATES_FEED_CLEAR } + trackingRepository.clearLogs() + feedItemsCount.value = trackingRepository.getLogsCount() + onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) + } finally { + loadingKeys.update { it - AppSettings.KEY_UPDATES_FEED_CLEAR } + } + } + } + + fun clearMangaData() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA } + trackingRepository.gc() + val repository = mangaDataRepositoryProvider.get() + repository.cleanupLocalManga() + repository.cleanupDatabase() + onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) + } finally { + loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA } + } + } + } + + fun cleanupChapters() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR } + val oldSize = storageUsage.firstNotNull().savedManga.bytes + val chaptersCount = deleteReadChaptersUseCase.invoke() + loadStorageUsage().join() + val newSize = storageUsage.firstNotNull().savedManga.bytes + onChaptersCleanedUp.call(chaptersCount to oldSize - newSize) + } finally { + loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR } + } + } + } + + private fun loadStorageUsage(): Job { + val prevJob = storageUsageJob + return launchJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES) + val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize + val storageSize = storageManager.computeStorageSize() + val availableSpace = storageManager.computeAvailableSize() + val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace + storageUsage.value = StorageUsage( + savedManga = StorageUsage.Item( + bytes = storageSize, + percent = (storageSize.toDouble() / totalBytes).toFloat(), + ), + pagesCache = StorageUsage.Item( + bytes = pagesCacheSize, + percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(), + ), + otherCache = StorageUsage.Item( + bytes = otherCacheSize, + percent = (otherCacheSize.toDouble() / totalBytes).toFloat(), + ), + available = StorageUsage.Item( + bytes = availableSpace, + percent = (availableSpace.toDouble() / totalBytes).toFloat(), + ), + ) + }.also { + storageUsageJob = it + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt similarity index 77% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt index 6ed2e4748..2b70279c2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings.userdata +package org.koitharu.kotatsu.settings.userdata.storage data class StorageUsage( val savedManga: Item, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt similarity index 89% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt index 6f12d7069..6e993f580 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt @@ -1,7 +1,8 @@ -package org.koitharu.kotatsu.settings.userdata +package org.koitharu.kotatsu.settings.userdata.storage import android.content.Context import android.content.res.ColorStateList +import android.graphics.Color import android.util.AttributeSet import androidx.annotation.StringRes import androidx.core.widget.TextViewCompat @@ -10,10 +11,9 @@ import androidx.preference.PreferenceViewHolder import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView -import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding -import com.google.android.material.R as materialR class StorageUsagePreference @JvmOverloads constructor( context: Context, @@ -34,15 +34,15 @@ class StorageUsagePreference @JvmOverloads constructor( val binding = PreferenceMemoryUsageBinding.bind(holder.itemView) val storageSegment = SegmentedBarView.Segment( usage?.savedManga?.percent ?: 0f, - KotatsuColors.segmentColor(context, materialR.attr.colorPrimary), + KotatsuColors.segmentColorRandom(context, Color.BLUE), ) val pagesSegment = SegmentedBarView.Segment( usage?.pagesCache?.percent ?: 0f, - KotatsuColors.segmentColor(context, materialR.attr.colorSecondary), + KotatsuColors.segmentColorRandom(context, Color.GREEN), ) val otherSegment = SegmentedBarView.Segment( usage?.otherCache?.percent ?: 0f, - KotatsuColors.segmentColor(context, materialR.attr.colorTertiary), + KotatsuColors.segmentColorRandom(context, Color.GRAY), ) with(binding) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt index 851ede594..3e2ca6509 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt @@ -3,17 +3,15 @@ package org.koitharu.kotatsu.settings.utils import androidx.preference.EditTextPreference import androidx.preference.Preference import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty class EditTextDefaultSummaryProvider( - private val defaultValue: String + private val defaultValue: String, ) : Preference.SummaryProvider { - override fun provideSummary(preference: EditTextPreference): CharSequence { - val text = preference.text - return if (text.isNullOrEmpty()) { - preference.context.getString(R.string.default_s, defaultValue) - } else { - text - } + override fun provideSummary( + preference: EditTextPreference, + ): CharSequence = preference.text.ifNullOrEmpty { + preference.context.getString(R.string.default_s, defaultValue) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt new file mode 100644 index 000000000..a57f405e1 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.settings.utils + +import androidx.annotation.StringRes +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty + +class EditTextFallbackSummaryProvider( + @StringRes private val fallbackResId: Int, +) : Preference.SummaryProvider { + + override fun provideSummary( + preference: EditTextPreference, + ): CharSequence = preference.text.ifNullOrEmpty { + preference.context.getString(fallbackResId) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt index f0e3cbe56..c2a2ac4d3 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsDao.kt @@ -40,6 +40,7 @@ abstract class StatsDao { favouriteCategories: Set ): Map { val conditions = ArrayList() + conditions.add("(SELECT deleted_at FROM history WHERE history.manga_id = stats.manga_id) = 0") conditions.add("stats.started_at >= $fromDate") if (favouriteCategories.isNotEmpty()) { val ids = favouriteCategories.joinToString(",") diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt index 9b621e1b0..54992260c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt @@ -33,7 +33,7 @@ class StatsRepository @Inject constructor( var other = StatsRecord(null, 0) val total = stats.values.sum() for ((mangaEntity, duration) in stats) { - val manga = mangaEntity.toManga(emptySet()) + val manga = mangaEntity.toManga(emptySet(), null) val percent = duration.toDouble() / total if (percent < 0.05) { other = other.copy(duration = other.duration + duration) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt index 141e9e487..3e5bc104d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt @@ -4,13 +4,17 @@ import android.os.Bundle import android.view.Menu import android.view.MenuItem import android.view.View +import android.view.ViewGroup import android.view.ViewStub import android.widget.CompoundButton import androidx.activity.viewModels import androidx.appcompat.widget.PopupMenu import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isGone import androidx.core.view.isVisible +import androidx.core.view.updateLayoutParams +import androidx.core.view.updatePaddingRelative import androidx.recyclerview.widget.AsyncListDiffer import coil3.ImageLoader import com.google.android.material.chip.Chip @@ -18,25 +22,27 @@ import com.google.android.material.chip.ChipDrawable import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.KotatsuColors +import org.koitharu.kotatsu.core.util.ext.end import org.koitharu.kotatsu.core.util.ext.enqueueWith import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setTextAndVisible import org.koitharu.kotatsu.core.util.ext.showOrHide +import org.koitharu.kotatsu.core.util.ext.start import org.koitharu.kotatsu.databinding.ActivityStatsBinding import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.stats.domain.StatsPeriod import org.koitharu.kotatsu.stats.domain.StatsRecord -import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import org.koitharu.kotatsu.stats.ui.views.PieChartView import javax.inject.Inject @@ -45,7 +51,9 @@ class StatsActivity : BaseActivity(), OnListItemClickListener, PieChartView.OnSegmentClickListener, AsyncListDiffer.ListListener, - ViewStub.OnInflateListener, View.OnClickListener, CompoundButton.OnCheckedChangeListener { + ViewStub.OnInflateListener, + View.OnClickListener, + CompoundButton.OnCheckedChangeListener { @Inject lateinit var coil: ImageLoader @@ -55,7 +63,7 @@ class StatsActivity : BaseActivity(), override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityStatsBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUp(true, false) val adapter = BaseListAdapter() .addDelegate(ListItemType.FEED, statsAD(this)) .addListListener(this) @@ -89,7 +97,38 @@ class StatsActivity : BaseActivity(), } } - override fun onWindowInsetsChanged(insets: Insets) = Unit + override fun onApplyWindowInsets( + v: View, + insets: WindowInsetsCompat + ): WindowInsetsCompat { + val bars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + val isTablet = viewBinding.guidelineCenter != null + viewBinding.appbar.updatePaddingRelative( + start = bars.start(v), + top = bars.top, + end = if (isTablet) 0 else bars.end(v), + ) + val badgePadding = resources.getDimensionPixelOffset(R.dimen.list_spacing_large) + viewBinding.scrollViewChips.updatePaddingRelative( + start = badgePadding + if (isTablet) 0 else bars.start(v), + end = badgePadding + bars.end(v), + top = if (isTablet) bars.top else 0, + ) + viewBinding.recyclerView.updatePaddingRelative( + start = if (isTablet) 0 else bars.start(v), + end = bars.end(v), + bottom = bars.bottom, + ) + viewBinding.chart.updateLayoutParams { + val baseMargin = topMargin + bottomMargin = if (isTablet) baseMargin + bars.bottom else baseMargin + marginStart = baseMargin + bars.start(v) + marginEnd = if (isTablet) baseMargin else baseMargin + bars.end(v) + } + return return WindowInsetsCompat.Builder(insets) + .setInsets(WindowInsetsCompat.Type.systemBars(), Insets.NONE) + .build() + } override fun onClick(v: View) { when (v.id) { @@ -103,7 +142,7 @@ class StatsActivity : BaseActivity(), } override fun onItemClick(item: Manga, view: View) { - MangaStatsSheet.show(supportFragmentManager, item) + router.showStatisticSheet(item) } override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt index 988bf32c4..a101ce6c0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt @@ -5,20 +5,18 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.collection.IntList -import androidx.fragment.app.FragmentManager +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.updatePadding import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.KotatsuColors +import org.koitharu.kotatsu.core.util.ext.consume import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.stats.ui.views.BarChartView @@ -45,8 +43,16 @@ class MangaStatsSheet : BaseAdaptiveSheet(), View.OnClic binding.buttonOpen.setOnClickListener(this) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + viewBinding?.scrollView?.updatePadding( + bottom = insets.getInsets(typeMask).bottom, + ) + return insets.consume(v, typeMask, bottom = true) + } + override fun onClick(v: View) { - startActivity(DetailsActivity.newIntent(v.context, viewModel.manga)) + router.openDetails(viewModel.manga) } private fun onStatsChanged(stats: IntList) { @@ -66,17 +72,4 @@ class MangaStatsSheet : BaseAdaptiveSheet(), View.OnClic } chartView.setData(bars) } - - companion object { - - const val ARG_MANGA = "manga" - - private const val TAG = "MangaStatsSheet" - - fun show(fm: FragmentManager, manga: Manga) { - MangaStatsSheet().withArgs(1) { - putParcelable(ARG_MANGA, ParcelableManga(manga)) - }.showDistinct(fm, TAG) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt index 9fc1f782b..7d0133608 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt @@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo @@ -23,7 +24,7 @@ class MangaStatsViewModel @Inject constructor( private val repository: StatsRepository, ) : BaseViewModel() { - val manga = savedStateHandle.require(MangaStatsSheet.ARG_MANGA).manga + val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga val stats = MutableStateFlow(emptyIntList()) val startDate = MutableStateFlow(null) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt index b7f8a4e6a..6a2e168cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.suggestions.data.SuggestionEntity +import org.koitharu.kotatsu.suggestions.data.SuggestionWithManga import javax.inject.Inject class SuggestionRepository @Inject constructor( @@ -23,25 +24,23 @@ class SuggestionRepository @Inject constructor( fun observeAll(): Flow> { return db.getSuggestionDao().observeAll().mapItems { - it.manga.toManga(it.tags.toMangaTags()) + it.toManga() } } fun observeAll(limit: Int, filterOptions: Set): Flow> { return db.getSuggestionDao().observeAll(limit, filterOptions).mapItems { - it.manga.toManga(it.tags.toMangaTags()) + it.toManga() } } suspend fun getRandom(): Manga? { - return db.getSuggestionDao().getRandom()?.let { - it.manga.toManga(it.tags.toMangaTags()) - } + return db.getSuggestionDao().getRandom()?.toManga() } suspend fun getRandomList(limit: Int): List { return db.getSuggestionDao().getRandom(limit).map { - it.manga.toManga(it.tags.toMangaTags()) + it.toManga() } } @@ -80,4 +79,6 @@ class SuggestionRepository @Inject constructor( } } } + + private fun SuggestionWithManga.toManga() = manga.toManga(tags.toMangaTags(), null) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt index 207091960..80f2f9064 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt @@ -1,48 +1,5 @@ package org.koitharu.kotatsu.suggestions.ui -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner +import org.koitharu.kotatsu.core.ui.FragmentContainerActivity -@AndroidEntryPoint -class SuggestionsActivity : - BaseActivity(), - AppBarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - replace(R.id.container, SuggestionsFragment::class.java, null) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, SuggestionsActivity::class.java) - } -} +class SuggestionsActivity : FragmentContainerActivity(SuggestionsFragment::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt index 92e192e1d..e29e969b8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt @@ -8,11 +8,11 @@ import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.settings.SettingsActivity class SuggestionsFragment : MangaListFragment() { @@ -59,7 +59,7 @@ class SuggestionsFragment : MangaListFragment() { } R.id.action_settings_suggestions -> { - startActivity(SettingsActivity.newSuggestionsSettingsIntent(requireContext())) + router.openSuggestionsSettings() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 8f0110f54..199c17735 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -49,6 +49,8 @@ import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.asArrayList @@ -56,13 +58,13 @@ import org.koitharu.kotatsu.core.util.ext.awaitUniqueWorkInfoByName import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.flatten +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.takeMostFrequent import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.trySetForeground -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository @@ -74,8 +76,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.sizeOrZero -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository @@ -130,7 +130,7 @@ class SuggestionsWorker @AssistedInject constructor( PendingIntentCompat.getActivity( applicationContext, 0, - SettingsActivity.newSuggestionsSettingsIntent(applicationContext), + AppRouter.suggestionsSettingsIntent(applicationContext), 0, false, ), @@ -312,7 +312,7 @@ class SuggestionsWorker @AssistedInject constructor( appendLine() bold { append( - applicationContext.resources.getQuantityString( + applicationContext.resources.getQuantityStringSafe( R.plurals.chapters, chaptersCount, chaptersCount, @@ -326,7 +326,7 @@ class SuggestionsWorker @AssistedInject constructor( style.setBigContentTitle(title) setStyle(style) } - val intent = DetailsActivity.newIntent(applicationContext, manga) + val intent = AppRouter.detailsIntent(applicationContext, manga) setContentIntent( PendingIntentCompat.getActivity( applicationContext, @@ -348,7 +348,7 @@ class SuggestionsWorker @AssistedInject constructor( PendingIntentCompat.getActivity( applicationContext, id + 2, - IntentBuilder(applicationContext).manga(manga).build(), + ReaderIntent.Builder(applicationContext).manga(manga).build().intent, 0, false, ), @@ -360,7 +360,7 @@ class SuggestionsWorker @AssistedInject constructor( PendingIntentCompat.getActivity( applicationContext, 0, - SuggestionsActivity.newIntent(applicationContext), + AppRouter.suggestionsIntent(applicationContext), 0, false, ), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt index 0c3fe09cb..fc44bbbc6 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/data/SyncSettings.kt @@ -6,6 +6,7 @@ import android.content.Context import androidx.annotation.WorkerThread import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty import javax.inject.Inject @@ -23,7 +24,7 @@ class SyncSettings( ) private val accountManager = AccountManager.get(context) - private val defaultSyncUrl = context.getString(R.string.sync_url_default) + private val defaultSyncUrl = context.resources.getStringArray(R.array.sync_url_list).first() @get:WorkerThread @set:WorkerThread @@ -39,10 +40,10 @@ class SyncSettings( companion object { - private fun String.withHttpSchema(): String = if (!startsWith("http://") && !startsWith("https://")) { - "http://$this" - } else { + private fun String.withHttpSchema(): String = if (isHttpUrl()) { this + } else { + "http://$this" } const val KEY_SYNC_URL = "host" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt index c8e71f564..886fc6445 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt @@ -11,7 +11,7 @@ import android.widget.Button import android.widget.Toast import androidx.activity.OnBackPressedCallback import androidx.activity.viewModels -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.isVisible import androidx.fragment.app.FragmentResultListener import androidx.transition.Fade @@ -20,10 +20,13 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivitySyncAuthBinding import org.koitharu.kotatsu.sync.data.SyncSettings import org.koitharu.kotatsu.sync.domain.SyncAuthResult @@ -65,14 +68,16 @@ class SyncAuthActivity : BaseActivity(), View.OnClickLi pageBackCallback.update() } - override fun onWindowInsetsChanged(insets: Insets) { + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets val basePadding = resources.getDimensionPixelOffset(R.dimen.screen_padding) viewBinding.root.setPadding( - basePadding + insets.left, - basePadding + insets.top, - basePadding + insets.right, - basePadding + insets.bottom, + barsInsets.left + basePadding, + barsInsets.top + basePadding, + barsInsets.right + basePadding, + barsInsets.bottom + basePadding, ) + return insets.consumeAllSystemBarsInsets() } override fun onClick(v: View) { @@ -187,11 +192,7 @@ class SyncAuthActivity : BaseActivity(), View.OnClickLi private class PasswordTextWatcher( private val button: Button, - ) : TextWatcher { - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + ) : DefaultTextWatcher { override fun afterTextChanged(s: Editable?) { val text = s?.toString() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt index 33d062b57..17cc2313f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthViewModel.kt @@ -22,7 +22,7 @@ class SyncAuthViewModel @Inject constructor( val onAccountAlreadyExists = MutableEventFlow() val onTokenObtained = MutableEventFlow() - val syncURL = MutableStateFlow(context.getString(R.string.sync_url_default)) + val syncURL = MutableStateFlow(context.resources.getStringArray(R.array.sync_url_list).first()) init { launchJob(Dispatchers.Default) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt index 1b04e6877..e4f000fed 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncHostDialogFragment.kt @@ -13,6 +13,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.AlertDialogFragment +import org.koitharu.kotatsu.core.util.ext.isHttpUrl import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.PreferenceDialogAutocompletetextviewBinding import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty @@ -66,7 +67,7 @@ class SyncHostDialogFragment : AlertDialogFragment { val result = requireViewBinding().edit.text?.toString().orEmpty() var scheme = "" - if (!result.startsWith("https://") && !result.startsWith("http://")) { + if (!result.isHttpUrl()) { scheme = "http://" } syncSettings.syncUrl = "$scheme$result" diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncSettingsIntent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncSettingsIntent.kt deleted file mode 100644 index 2775d8072..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncSettingsIntent.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.sync.ui - -import android.accounts.Account -import android.content.Intent -import android.os.Bundle - -private const val ACCOUNT_KEY = "account" -private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS" -private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" - -@Suppress("FunctionName") -fun SyncSettingsIntent(account: Account): Intent { - val args = Bundle(1) - args.putParcelable(ACCOUNT_KEY, account) - val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS) - intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args) - return intent -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt index 5af404b93..cf9e42e97 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt @@ -10,7 +10,7 @@ fun TrackLogWithManga.toTrackingLogItem(): TrackingLogItem { return TrackingLogItem( id = trackLog.id, chapters = chaptersList, - manga = manga.toManga(tags.toMangaTags()), + manga = manga.toManga(tags.toMangaTags(), null), createdAt = Instant.ofEpochMilli(trackLog.createdAt), isNew = trackLog.isUnread, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt index 127a60b4c..925508f1d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/TrackEntity.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.tracker.data +import androidx.annotation.IntDef import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.ForeignKey @@ -24,10 +25,15 @@ class TrackEntity( @ColumnInfo(name = "chapters_new") val newChapters: Int, @ColumnInfo(name = "last_check_time") val lastCheckTime: Long, @ColumnInfo(name = "last_chapter_date") val lastChapterDate: Long, + @TrackerResult @ColumnInfo(name = "last_result") val lastResult: Int, @ColumnInfo(name = "last_error") val lastError: String?, ) { + @IntDef(RESULT_NONE, RESULT_HAS_UPDATE, RESULT_NO_UPDATE, RESULT_FAILED, RESULT_EXTERNAL_MODIFICATION) + @Retention(AnnotationRetention.SOURCE) + annotation class TrackerResult + companion object { const val RESULT_NONE = 0 diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index 5aabf2f51..dfe81770f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -60,7 +60,7 @@ class TrackingRepository @Inject constructor( return db.getTracksDao().observeUpdatedManga(limit, filterOptions) .mapItems { MangaTracking( - manga = it.manga.toManga(it.tags.toMangaTags()), + manga = it.manga.toManga(it.tags.toMangaTags(), null), lastChapterId = it.track.lastChapterId, lastCheck = it.track.lastCheckTime.toInstantOrNull(), lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), @@ -73,7 +73,7 @@ class TrackingRepository @Inject constructor( suspend fun getTracks(offset: Int, limit: Int): List { return db.getTracksDao().findAll(offset = offset, limit = limit).map { MangaTracking( - manga = it.manga.toManga(emptySet()), + manga = it.manga.toManga(emptySet(), null), lastChapterId = it.track.lastChapterId, lastCheck = it.track.lastCheckTime.toInstantOrNull(), lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt index 18d02dd3e..70eabceb9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt @@ -3,16 +3,18 @@ package org.koitharu.kotatsu.tracker.ui.debug import android.os.Bundle import android.view.View import androidx.activity.viewModels -import androidx.core.graphics.Insets +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityTrackerDebugBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import javax.inject.Inject @@ -28,7 +30,7 @@ class TrackerDebugActivity : BaseActivity(), OnList override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityTrackerDebugBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) + setDisplayHomeAsUp(true, false) val tracksAdapter = BaseListAdapter() .addDelegate(ListItemType.FEED, trackDebugAD(this, coil, this)) with(viewBinding.recyclerView) { @@ -39,20 +41,22 @@ class TrackerDebugActivity : BaseActivity(), OnList viewModel.content.observe(this, tracksAdapter) } - override fun onWindowInsetsChanged(insets: Insets) { - val rv = viewBinding.recyclerView - rv.updatePadding( - left = insets.left + rv.paddingTop, - right = insets.right + rv.paddingTop, - bottom = insets.bottom, + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.recyclerView.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, ) - viewBinding.toolbar.updatePadding( - left = insets.left, - right = insets.right, + viewBinding.appbar.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + top = barsInsets.top, ) + return insets.consumeAllSystemBarsInsets() } override fun onItemClick(item: TrackDebugItem, view: View) { - startActivity(DetailsActivity.newIntent(this, item.manga)) + router.openDetails(item.manga) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt index b9b8866ec..a995979b0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt @@ -25,7 +25,7 @@ class TrackerDebugViewModel @Inject constructor( private fun List.toUiList(): List = map { TrackDebugItem( - manga = it.manga.toManga(emptySet()), + manga = it.manga.toManga(emptySet(), null), lastChapterId = it.track.lastChapterId, newChapters = it.track.newChapters, lastCheckTime = it.track.lastCheckTime.toInstantOrNull(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt index 6fb310599..92930937c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt @@ -4,51 +4,55 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import androidx.fragment.app.viewModels import androidx.recyclerview.widget.LinearLayoutManager import androidx.recyclerview.widget.RecyclerView import androidx.swiperefreshlayout.widget.SwipeRefreshLayout import coil3.ImageLoader -import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.drop import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper import org.koitharu.kotatsu.core.ui.util.MenuInvalidator +import org.koitharu.kotatsu.core.ui.util.RecyclerViewOwner +import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.ui.widgets.TipView import org.koitharu.kotatsu.core.util.ext.addMenuProvider +import org.koitharu.kotatsu.core.util.ext.consumeAll import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentListBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.size.StaticItemSizeResolver -import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter -import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity import javax.inject.Inject @AndroidEntryPoint class FeedFragment : BaseFragment(), PaginationScrollListener.Callback, - MangaListListener, SwipeRefreshLayout.OnRefreshListener { + RecyclerViewOwner, + MangaListListener, + SwipeRefreshLayout.OnRefreshListener { @Inject lateinit var coil: ImageLoader private val viewModel by viewModels() + override val recyclerView: RecyclerView? + get() = viewBinding?.recyclerView + override fun onCreateViewBinding( inflater: LayoutInflater, container: ViewGroup?, @@ -77,15 +81,21 @@ class FeedFragment : viewModel.isHeaderEnabled.drop(1).observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) viewModel.content.observe(viewLifecycleOwner, feedAdapter) viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this)) - viewModel.onFeedCleared.observeEvent(viewLifecycleOwner) { onFeedCleared() } + viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(binding.recyclerView)) viewModel.isRunning.observe(viewLifecycleOwner, this::onIsTrackerRunningChanged) } - override fun onWindowInsetsChanged(insets: Insets) { - val rv = requireViewBinding().recyclerView - rv.updatePadding( - bottom = insets.bottom + rv.paddingTop, + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val typeMask = WindowInsetsCompat.Type.systemBars() + val barsInsets = insets.getInsets(typeMask) + val paddingVertical = resources.getDimensionPixelSize(R.dimen.list_spacing_normal) + viewBinding?.recyclerView?.setPadding( + left = barsInsets.left, + top = paddingVertical, + right = barsInsets.right, + bottom = barsInsets.bottom + paddingVertical, ) + return insets.consumeAll(typeMask) } override fun onRefresh() { @@ -105,18 +115,7 @@ class FeedFragment : override fun onSecondaryButtonClick(tipView: TipView) = Unit override fun onListHeaderClick(item: ListHeader, view: View) { - val context = view.context - context.startActivity(UpdatesActivity.newIntent(context)) - } - - private fun onFeedCleared() { - val snackbar = Snackbar.make( - requireViewBinding().recyclerView, - R.string.updates_feed_cleared, - Snackbar.LENGTH_LONG, - ) - snackbar.anchorView = (activity as? BottomNavOwner)?.bottomNav - snackbar.show() + router.openMangaUpdates() } private fun onIsTrackerRunningChanged(isRunning: Boolean) { @@ -128,7 +127,7 @@ class FeedFragment : } override fun onItemClick(item: Manga, view: View) { - startActivity(DetailsActivity.newIntent(context ?: return, item)) + router.openDetails(item) } override fun onReadClick(manga: Manga, view: View) = Unit diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt index 598d57455..f2c23cbdc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt @@ -16,10 +16,12 @@ import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.DateTimeAgo +import org.koitharu.kotatsu.core.ui.util.ReversibleAction import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo import org.koitharu.kotatsu.core.util.ext.call @@ -64,7 +66,7 @@ class FeedViewModel @Inject constructor( valueProducer = { isFeedHeaderVisible }, ) - val onFeedCleared = MutableEventFlow() + val onActionDone = MutableEventFlow() @Suppress("USELESS_CAST") val content = combine( @@ -106,7 +108,7 @@ class FeedViewModel @Inject constructor( if (clearCounters) { repository.clearCounters() } - onFeedCleared.call(Unit) + onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) } } @@ -151,7 +153,7 @@ class FeedViewModel @Inject constructor( null } else { UpdatedMangaHeader( - mangaList.map { mangaListMapper.toGridModel(it.manga) }, + mangaList.map { mangaListMapper.toListModel(it.manga, ListMode.GRID) }, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt index 0f67d0f1f..75b71b11a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/adapter/FeedItemAD.kt @@ -10,6 +10,7 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders import org.koitharu.kotatsu.core.util.ext.drawableStart import org.koitharu.kotatsu.core.util.ext.enqueueWith +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.newImageRequest import org.koitharu.kotatsu.databinding.ItemFeedBinding @@ -37,7 +38,7 @@ fun feedItemAD( enqueueWith(coil) } binding.textViewTitle.text = item.title - binding.textViewSummary.text = context.resources.getQuantityString( + binding.textViewSummary.text = context.resources.getQuantityStringSafe( R.plurals.new_chapters, item.count, item.count, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt index 047b0d7ca..4004a9afe 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt @@ -1,49 +1,5 @@ package org.koitharu.kotatsu.tracker.ui.updates -import android.content.Context -import android.content.Intent -import android.os.Bundle -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding -import androidx.fragment.app.commit -import com.google.android.material.appbar.AppBarLayout -import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity -import org.koitharu.kotatsu.databinding.ActivityContainerBinding -import org.koitharu.kotatsu.main.ui.owners.AppBarOwner +import org.koitharu.kotatsu.core.ui.FragmentContainerActivity -@AndroidEntryPoint -class UpdatesActivity : - BaseActivity(), - AppBarOwner { - - override val appBar: AppBarLayout - get() = viewBinding.appbar - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - setContentView(ActivityContainerBinding.inflate(layoutInflater)) - supportActionBar?.setDisplayHomeAsUpEnabled(true) - val fm = supportFragmentManager - if (fm.findFragmentById(R.id.container) == null) { - fm.commit { - setReorderingAllowed(true) - val fragment = UpdatesFragment.newInstance() - replace(R.id.container, fragment) - } - } - } - - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - ) - } - - companion object { - - fun newIntent(context: Context) = Intent(context, UpdatesActivity::class.java) - } -} +class UpdatesActivity : FragmentContainerActivity(UpdatesFragment::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt index 01b4afb3c..e918aba0a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesFragment.kt @@ -38,9 +38,4 @@ class UpdatesFragment : MangaListFragment() { else -> super.onActionItemClicked(controller, mode, item) } } - - companion object { - - fun newInstance() = UpdatesFragment() - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 8f2e55dc8..323bb1f4b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -46,6 +46,7 @@ import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.ids +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy import org.koitharu.kotatsu.core.prefs.TriStateOption @@ -59,7 +60,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toIntUp -import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase import org.koitharu.kotatsu.tracker.domain.GetTracksUseCase @@ -209,7 +209,7 @@ class TrackWorker @AssistedInject constructor( PendingIntentCompat.getActivity( applicationContext, 0, - SettingsActivity.newTrackerSettingsIntent(applicationContext), + AppRouter.trackerSettingsIntent(applicationContext), 0, false, ), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt index dc18149a5..54bb294a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt @@ -14,20 +14,21 @@ import androidx.core.app.PendingIntentCompat import androidx.core.content.ContextCompat import coil3.ImageLoader import coil3.request.ImageRequest -import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.LocalizedAppContext +import org.koitharu.kotatsu.core.model.getLocalizedTitle +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.getQuantityStringSafe import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity import javax.inject.Inject class TrackerNotificationHelper @Inject constructor( - @ApplicationContext private val applicationContext: Context, + @LocalizedAppContext private val applicationContext: Context, private val settings: AppSettings, private val coil: ImageLoader, ) { @@ -55,7 +56,7 @@ class TrackerNotificationHelper @Inject constructor( } val id = manga.url.hashCode() val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) - val summary = applicationContext.resources.getQuantityString( + val summary = applicationContext.resources.getQuantityStringSafe( R.plurals.new_chapters, newChapters.size, newChapters.size, @@ -76,12 +77,12 @@ class TrackerNotificationHelper @Inject constructor( setGroup(GROUP_NEW_CHAPTERS) val style = NotificationCompat.InboxStyle(this) for (chapter in newChapters) { - style.addLine(chapter.name) + style.addLine(chapter.getLocalizedTitle(applicationContext.resources)) } style.setSummaryText(manga.title) style.setBigContentTitle(summary) setStyle(style) - val intent = DetailsActivity.newIntent(applicationContext, manga) + val intent = AppRouter.detailsIntent(applicationContext, manga) setContentIntent( PendingIntentCompat.getActivity( applicationContext, @@ -107,7 +108,7 @@ class TrackerNotificationHelper @Inject constructor( val newChaptersCount = notifications.sumOf { it.newChapters } val builder = NotificationCompat.Builder(applicationContext, CHANNEL_ID) with(builder) { - val title = applicationContext.resources.getQuantityString( + val title = applicationContext.resources.getQuantityStringSafe( R.plurals.new_chapters, newChaptersCount, newChaptersCount, @@ -126,7 +127,7 @@ class TrackerNotificationHelper @Inject constructor( setNumber(newChaptersCount) setGroup(GROUP_NEW_CHAPTERS) setGroupSummary(true) - val intent = UpdatesActivity.newIntent(applicationContext) + val intent = AppRouter.mangaUpdatesIntent(applicationContext) setContentIntent( PendingIntentCompat.getActivity( applicationContext, @@ -190,7 +191,6 @@ class TrackerNotificationHelper @Inject constructor( const val TAG = "tracker" private const val LEGACY_CHANNELS_GROUP_ID = "trackers" - private const val LEGACY_CHANNEL_ID_PREFIX = "track_fav_" private const val LEGACY_CHANNEL_ID_HISTORY = "track_history" private const val LEGACY_CHANNEL_ID = "tracking" } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt index a4e047999..4359d1e9d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt @@ -14,7 +14,7 @@ import coil3.transform.RoundedCornersTransformation import dagger.Lazy import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.mangaExtra @@ -74,7 +74,7 @@ class RecentListFactory( views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder) } val intent = Intent() - intent.putExtra(MangaIntent.KEY_ID, item.id) + intent.putExtra(AppRouter.KEY_ID, item.id) views.setOnClickFillInIntent(R.id.imageView_cover, intent) return views } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetConfigActivity.kt index aba7d594d..f5a42535f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetConfigActivity.kt @@ -1,18 +1,17 @@ package org.koitharu.kotatsu.widget.recent -import android.app.Activity import android.appwidget.AppWidgetManager import android.content.Intent import android.os.Bundle import android.view.View -import androidx.core.graphics.Insets -import androidx.core.view.updatePadding +import androidx.core.view.WindowInsetsCompat import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityAppwidgetRecentBinding -import com.google.android.material.R as materialR @AndroidEntryPoint class RecentWidgetConfigActivity : @@ -24,10 +23,7 @@ class RecentWidgetConfigActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityAppwidgetRecentBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } + setDisplayHomeAsUp(true, true) viewBinding.buttonDone.setOnClickListener(this) val appWidgetId = intent?.getIntExtra( AppWidgetManager.EXTRA_APPWIDGET_ID, @@ -41,13 +37,24 @@ class RecentWidgetConfigActivity : viewBinding.switchBackground.isChecked = config.hasBackground } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.root.setPadding( + barsInsets.left, + barsInsets.top, + barsInsets.right, + barsInsets.bottom, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onClick(v: View) { when (v.id) { R.id.button_done -> { config.hasBackground = viewBinding.switchBackground.isChecked updateWidget() setResult( - Activity.RESULT_OK, + RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId), ) finish() @@ -55,15 +62,6 @@ class RecentWidgetConfigActivity : } } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.root.updatePadding( - left = insets.left, - right = insets.right, - bottom = insets.bottom, - top = insets.top, - ) - } - private fun updateWidget() { val intent = Intent(this, RecentWidgetProvider::class.java) intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt index c94e449b9..380eb1cca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.widget.RemoteViews import androidx.core.app.PendingIntentCompat import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseAppWidgetProvider import org.koitharu.kotatsu.reader.ui.ReaderActivity @@ -36,7 +37,7 @@ class RecentWidgetProvider : BaseAppWidgetProvider() { adapter.data = Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)) views.setRemoteAdapter(R.id.stackView, adapter) val intent = Intent(context, ReaderActivity::class.java) - intent.action = ReaderActivity.ACTION_MANGA_READ + intent.action = ReaderIntent.ACTION_MANGA_READ views.setPendingIntentTemplate( R.id.stackView, PendingIntentCompat.getActivity( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt index 26d4cee95..a6747ffe1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt @@ -14,7 +14,7 @@ import coil3.transform.RoundedCornersTransformation import dagger.Lazy import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.image.TrimTransformation @@ -85,7 +85,7 @@ class ShelfListFactory( views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder) } val intent = Intent() - intent.putExtra(MangaIntent.KEY_ID, item.id) + intent.putExtra(AppRouter.KEY_ID, item.id) views.setOnClickFillInIntent(R.id.rootLayout, intent) return views } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt index f3685c47d..4c2ee770e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetConfigActivity.kt @@ -1,14 +1,11 @@ package org.koitharu.kotatsu.widget.shelf -import android.app.Activity import android.appwidget.AppWidgetManager import android.content.Intent import android.os.Bundle import android.view.View -import android.view.ViewGroup import androidx.activity.viewModels -import androidx.core.graphics.Insets -import androidx.core.view.updateLayoutParams +import androidx.core.view.WindowInsetsCompat import androidx.core.view.updatePadding import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R @@ -16,12 +13,13 @@ import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener +import org.koitharu.kotatsu.core.util.ext.consumeAllSystemBarsInsets import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.core.util.ext.systemBarsInsets import org.koitharu.kotatsu.databinding.ActivityAppwidgetShelfBinding import org.koitharu.kotatsu.widget.shelf.adapter.CategorySelectAdapter import org.koitharu.kotatsu.widget.shelf.model.CategoryItem -import com.google.android.material.R as materialR @AndroidEntryPoint class ShelfWidgetConfigActivity : @@ -37,10 +35,7 @@ class ShelfWidgetConfigActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityAppwidgetShelfBinding.inflate(layoutInflater)) - supportActionBar?.run { - setDisplayHomeAsUpEnabled(true) - setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) - } + setDisplayHomeAsUp(true, true) adapter = CategorySelectAdapter(this) viewBinding.recyclerView.adapter = adapter viewBinding.buttonDone.setOnClickListener(this) @@ -60,6 +55,21 @@ class ShelfWidgetConfigActivity : viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) } + override fun onApplyWindowInsets(v: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val barsInsets = insets.systemBarsInsets + viewBinding.recyclerView.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + bottom = barsInsets.bottom, + ) + viewBinding.appbar.updatePadding( + left = barsInsets.left, + right = barsInsets.right, + top = barsInsets.top, + ) + return insets.consumeAllSystemBarsInsets() + } + override fun onClick(v: View) { when (v.id) { R.id.button_done -> { @@ -67,7 +77,7 @@ class ShelfWidgetConfigActivity : config.hasBackground = viewBinding.switchBackground.isChecked updateWidget() setResult( - Activity.RESULT_OK, + RESULT_OK, Intent().putExtra(AppWidgetManager.EXTRA_APPWIDGET_ID, config.widgetId), ) finish() @@ -79,23 +89,6 @@ class ShelfWidgetConfigActivity : viewModel.checkedId = item.id } - override fun onWindowInsetsChanged(insets: Insets) { - viewBinding.recyclerView.updatePadding( - left = insets.left, - right = insets.right, - bottom = insets.bottom, - ) - with(viewBinding.toolbar) { - updatePadding( - left = insets.left, - right = insets.right, - ) - updateLayoutParams { - topMargin = insets.top - } - } - } - private fun updateWidget() { val intent = Intent(this, ShelfWidgetProvider::class.java) intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt index 5246ecd3f..f56e11e0d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.widget.RemoteViews import androidx.core.app.PendingIntentCompat import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseAppWidgetProvider import org.koitharu.kotatsu.reader.ui.ReaderActivity @@ -36,7 +37,7 @@ class ShelfWidgetProvider : BaseAppWidgetProvider() { adapter.data = Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)) views.setRemoteAdapter(R.id.gridView, adapter) val intent = Intent(context, ReaderActivity::class.java) - intent.action = ReaderActivity.ACTION_MANGA_READ + intent.action = ReaderIntent.ACTION_MANGA_READ views.setPendingIntentTemplate( R.id.gridView, PendingIntentCompat.getActivity( diff --git a/app/src/main/res/color-v23/bg_floating_button.xml b/app/src/main/res/color-v23/bg_floating_button.xml new file mode 100644 index 000000000..1ebbd47ea --- /dev/null +++ b/app/src/main/res/color-v23/bg_floating_button.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/color/bg_floating_button.xml b/app/src/main/res/color/bg_floating_button.xml new file mode 100644 index 000000000..b40058de7 --- /dev/null +++ b/app/src/main/res/color/bg_floating_button.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_list_icons.xml b/app/src/main/res/drawable/bg_list_icons.xml new file mode 100644 index 000000000..3ed4e9fe4 --- /dev/null +++ b/app/src/main/res/drawable/bg_list_icons.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_tab_pill.xml b/app/src/main/res/drawable/bg_tab_pill.xml new file mode 100644 index 000000000..246f0c385 --- /dev/null +++ b/app/src/main/res/drawable/bg_tab_pill.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_disable.xml b/app/src/main/res/drawable/ic_disable.xml new file mode 100644 index 000000000..ed59a0e86 --- /dev/null +++ b/app/src/main/res/drawable/ic_disable.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_move_horizontal.xml b/app/src/main/res/drawable/ic_move_horizontal.xml new file mode 100644 index 000000000..5c67c54df --- /dev/null +++ b/app/src/main/res/drawable/ic_move_horizontal.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml new file mode 100644 index 000000000..6c80ccb83 --- /dev/null +++ b/app/src/main/res/drawable/ic_next.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_prev.xml b/app/src/main/res/drawable/ic_prev.xml new file mode 100644 index 000000000..8822aa243 --- /dev/null +++ b/app/src/main/res/drawable/ic_prev.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout-land/item_empty_state.xml b/app/src/main/res/layout-land/item_empty_state.xml index f039c9cbc..660a4a677 100644 --- a/app/src/main/res/layout-land/item_empty_state.xml +++ b/app/src/main/res/layout-land/item_empty_state.xml @@ -5,7 +5,7 @@ android:layout_width="match_parent" android:layout_height="match_parent" android:gravity="center" - android:orientation="vertical" + android:orientation="horizontal" android:paddingHorizontal="32dp"> - + android:orientation="vertical"> - + - diff --git a/app/src/main/res/values-ar/plurals.xml b/app/src/main/res/values-ar/plurals.xml index efbe8edc1..035f444e4 100644 --- a/app/src/main/res/values-ar/plurals.xml +++ b/app/src/main/res/values-ar/plurals.xml @@ -72,4 +72,4 @@ %1$d ساعة %1$d ساعة - \ No newline at end of file + diff --git a/app/src/main/res/values-ar/strings.xml b/app/src/main/res/values-ar/strings.xml index 57a074cc5..4495a4188 100644 --- a/app/src/main/res/values-ar/strings.xml +++ b/app/src/main/res/values-ar/strings.xml @@ -7,7 +7,7 @@ وضع القائمة الإعدادات مصادر المانجا - فصول + الفصول المُفضلة ‌خطاء في الشبكة جار التحميل… @@ -17,7 +17,7 @@ جاري الحوسبة … التخزين المحلي السجل - القائمه + اللائحة محو سجل أضف للمفضلة أضف @@ -344,7 +344,7 @@ عرض الشبكة تفاصيل الخطأ:<br><tt>%1$s</tt><br><br>1. حاول <a href=\"%2$s\">فتح المانجا في متصفح ويب</a> للتأكد من أنها متوفرة على مصدرها<br>2. تأكد من أنك تستخدم <a href=kotatsu://about>أحدث إصدار من Kotatsu</a><br>3. إذا كانت متوفرة، أرسل تقرير خطأ إلى المطورين. إتاحة المانجا الحديثة بالضغط المطول على أيقونة التطبيق - النقر على الحافة اليمنى أو الضغط على المفتاح الأيمن يؤدي دائمًا إلى الانتقال للصفحة التالية. + النقر على الحافة اليمنى أو الضغط على المفتاح الأيمن يؤدي دائمًا إلى الانتقال للصفحة التالية تحكم مريح في القراءة سطوع قم أيضًا بمسح المعلومات حول الفصول الجديدة @@ -357,7 +357,7 @@ اعادة تحميل المحتوى تفعيل التسجيل أسوكا - سيتم حذف سجل التحميلات خاصتك بشكل دائم + سيتم حذف سجل التنزيلات الخاص بك بشكل نهائي. لن تتأثر أي ملفات تم تنزيلها الديناميكية تسجيل على كونها الحالي ماميمي @@ -658,8 +658,141 @@ الاتصال جيد خارجي/إضافي مكون إضافي غير متوافق أو خطأ داخلي. تأكد من استخدام أحدث إصدار من المكون الإضافي وKotatsu - mangatime.org - حاول مجدداً + عنوان الخادم غير صالح + ‮حاول مجددا الصفحات المحفوظة هنالك الكثير من الطلبات. حاول مرة أخرى بعد%s + تسجيل الدخول إلى %s للمتابعة + سجّل الدخول لإعداد التكامل مع %s. سيسمح لك هذا بتتبع تقدمك في قراءة المانجا وحالتك + ميزة غير مستقرة + هذه الخاصية تجريبية. يرجى التأكد من وجود نسخة احتياطية لتجنب فقدان البيانات + شائعة اليوم + السنة + تم إعادة ضبط الاتصال + إظهار شريط التمرير + متخفي + التحقق من حساب Telegram + اختبار اتصال Telegram + لم يتم تعيين معرف دردشة Telegram + معرف دردشة Telegram + فتح روبوت Telegram + %d s + %1$d m %2$d s + خطأ في الإضافة: %s\n تأكد من أنك تستخدم أحدث إصدار من الإضافة و كوتاتسو + تكوين الوكيل غير صالح + تخطي الكل + عالقة + تم التحديث منذ وقت طويل + تقييم منخفض + ترتيب تصاعدي + ترتيب تنازلي + حسب التاريخ + الشعبية + شائعة في الشهر + شائعة في العام + اللغة الأصلية + التركيبة السكانية + شونين + شوجو + سينين + جوسي + سنوات + أي + كودومو + فصل واحد + دوجينشي + مجموعة صور + CG الفنان + هذا المصدر لا يدعم البحث باستخدام عوامل التصفية. تم مسح عوامل التصفية الخاصة بك + CG اللعبة + تصحيح الأخطاء + التعليمات البرمجية المصدرية + دليل المستخدم + مجموعة Telegram + أفقي + معرض + مطلوب اختبار CAPTCHA + معالجة الروابط + معالجة الروابط باستخدام هذا التطبيق + البريد الإلكتروني + غير شعبي + يمكنك تحديد الفصول لتنزيلها عن طريق النقر المطول على العنصر في قائمة الفصول. + تمت إضافة التنزيل + المزيد من الخيارات + دليل الوجهة + جميع الفصول + هل تريد السماح بالتنزيل عبر شبكة الجوال؟ + السماح دائمًا + تنسيق غير صالح: الصورة المتوقعة ولكن حصلت على %s + النوع + التنزيل عبر شبكة الجوال + عدم السماح + تم رفض الوصول (403) + الحد الأقصى لعدد النسخ الاحتياطية + إزالة الإدخالات غير المستخدمة من قاعدة البيانات + مسح قاعدة البيانات + إرسال النسخ الاحتياطية إلى Telegram + اختبار الاتصال + تشغيل روبوت Telegram + الترجمة + إظهار عوامل التصفية السريعة + إظهار عوامل التصفية السريعة فوق لوحة المفاتيح + آمن للاستخدام في العمل + التنزيلات في الخلفية + تنزيل الفصول الجديدة + المانجا التي تم تنزيل فصول منها + تم استبدال المانجا \"%1$s\" (%2$s) بـ \"%3$s\" (%4$s) + لا يتطلب الإصلاح لـ ”%s“ + جاري إصلاح المانجا + تم الإصلاح + لم يتم العثور على بدائل ل ”%s“ + سيقوم هذا الخيار بالبحث عن مصادر بديلة للمانجا المحددة. ستستغرق العملية بعض الوقت وستتم في الخلفية + بدء التنزيل + حفظ المانجا + حفظ المانجا المحددة؟ قد يستهلك ذلك حركة البيانات ومساحة التخزين + ليس في المفضلة + تنسيق صورة غير مدعوم: %s + رواية + مانهوا + أضيفت مؤخرًا + أضيفت منذ وقت طويل + شائعة في الساعة + شائعة في الأسبوع + السماح مرة واحدة + السؤال في كل مرة + منهوا + اتجاه الشاشة + حذف النسخ الاحتياطية القديمة + حذف النسخ الاحتياطية القديمة تلقائيًا + معرف دردشة Telegram المستلمة + تمكين جميع المصادر الموجودة + تم تمكين جميع المصادر + المؤلف + التقييم + المصدر + تمكين جميع المصادر + تفاصيل الخطأ + حاول فتح المانجا في متصفح الويب للتأكد من توفرها في مصدرها. + يمكنك إرسال تقرير عن الأخطاء إلى المطورين. سيساعدنا هذا في التحقق من المشكلة وإصلاحها. + استعادة النسخة الاحتياطية + البحث في المصادر المعطلة + شريط معلومات القارئ الشفاف + المجلد %1$s الفصل %2$s + الفصل %s + فصل غير مسمى + بسيط + ادوات التحكم بالقارئ في الشريط السفلي + الفصول و الصفحات + شريط تمرير الصفحة + تم ايقاف دوران الشاشة + تم تشغيل دوران الشاشة + رابط المانغا على %s + رابط المانغا في Kotatsu + سوف يتم استعادة النسخة الاحتياطية في الخلفية + يبدو أن إصدار Kotatsu الخاص بك قديم. يرجى تثبيت أحدث إصدار للحصول على جميع الإصلاحات المتاحة. + تعطيل اشعارات captcha + لن تتلقى إشعارات حول حل CAPTCHA لهذا المصدر ولكن هذا قد يؤدي إلى تعطيل العمليات الخلفية (التحقق من وجود فصول جديدة، الحصول على توصيات، وما إلى ذلك...) + البحث العالمي + البحث في كل مكان + الشارات في القوائم diff --git a/app/src/main/res/values-arq/plurals.xml b/app/src/main/res/values-arq/plurals.xml new file mode 100644 index 000000000..a6b3daec9 --- /dev/null +++ b/app/src/main/res/values-arq/plurals.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/app/src/main/res/values-as/plurals.xml b/app/src/main/res/values-as/plurals.xml new file mode 100644 index 000000000..fa4807914 --- /dev/null +++ b/app/src/main/res/values-as/plurals.xml @@ -0,0 +1,39 @@ + + + + %1$d অধ্যায় + %1$d অধ্যাযয়সমূহ + + + %1$d ঘণ্টা আগতে + %1$d ঘণ্টা পূৰ্বে + + + %1$d মাহ আগতে + %1$d মাহ পূৰ্বে + + + %1$d ঘণ্টা + %1$d ঘণ্টা + + + %1$d মিনিট + %1$d মিনিট + + + %1$d বস্তু + %1$d বস্তুসমূহ + + + %1$d দিন আগতে + %1$d দিন পূৰ্বে + + + %1$d মিনিট আগতে + %1$d মিনিট পূৰ্বে + + + %1$d নতুন অধ্যায় + %1$d নতুন অধ্যাযয়সমূহ + + \ No newline at end of file diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 971cc3a54..43f092128 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -743,4 +743,5 @@ Пытацца кожны раз Арыентацыя экрана Партрэтная - \ No newline at end of file + Старонкі захоўваюцца + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index cf089e778..4e06ede0f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -243,7 +243,7 @@ Automatické rolování Kap. %1$d/%2$d Str. %3$d/%4$d Zobrazovat informační lištu v čtečce - Archív komiksů + Archiv komiksů Složka s obrázky Importuji mangu Importování brzy začne @@ -251,7 +251,7 @@ Podrobnosti chyby:<br><tt>%1$s</tt><br><br>1. Zkuste <a href=%2$s>otveřít mangu v prohlížeči</a> abyste se ujistili že je dostupná na zdroji<br>2. Ujistěte se že používáte <a href=kotatsu://about>nejnovější verzi Kotatsu</a><br>3. Pokud je dostupná, pošlete hlášení o chybě vývojářům. Zobrazovat zkratky nedávných mang Udělejte nedávné mangy dostupné dlouhým kliknutím na ikonu aplikace - Neměň směr přepínání stránek v režimu čtení, například stisknutí pravé klávesy vždy přepne na další stránku. Tato volba ovlivňuje pouze hardwarová vstupní zařízení. + Neměňte směr přepínání stránek v režimu čtení; například stisknutí šipky vpravo vždy přepne na další stránku. Tato volba ovlivňuje pouze hardwarová vstupní zařízení Ovládání ergonomické čtečky Korekce barev Jas @@ -295,7 +295,7 @@ Klikněte a přidržte na předmětu pro přeskupení Záhlaví UserAgent Prosíme restartujte aplikaci pro aplikování těchto změn - Můžete vybrat jeden nebo vícs .cbz nebo .zip souborů, každý soubor bude znám jako samostatná manga. + Můžete vybrat jeden či více .cbz nebo .zip souborů, každý soubor bude brán jako samostatná manga. Zobrazovat na poličce Můžete se přihlásit do již existujícího účtu nebo vytvořit nový Najít podobné @@ -307,7 +307,7 @@ Pozastavit Vrátit Pozastaveno - Odstranění dokončeno + Odstranit dokončené Zastavit stahování při měnění na mobilní data Doporučení: %s Občas zobrazit oznámení s navrženou mangou @@ -315,7 +315,7 @@ Zapnout Ne, děkuji Všechna aktivní stahování budou zrušena, částečně stažená data budou ztracena - Historie stahování bude permanentně odstraněna. Žádné stažené soubory nebudou ovlivněny. + Historie stahování bude permanentně odstraněna. Žádné stažené soubory nebudou ovlivněny Nemáte žádná stažení Stahování bylo vráceno Stahování bylo pozastaveno @@ -414,7 +414,7 @@ Náhodné Importování dokončeno Můžete odstranit originální soubor z uložiště abyste ušetřili místo - Můžete vybrat adresář s archivy nebo obrázky. Každý archiv (nebo podkategorie) bude znám jako kapitola. + Můžete vybrat adresář s archivy nebo obrázky. Každý archiv (nebo podkategorie) bude brán jako kapitola. Rychlost Stahovat pouze přes Wi-Fi Můžete použít samostatně hostovaný synchronizační server nebo základní. Neměňte pokud si nejste jisti co děláte. @@ -538,7 +538,7 @@ %d m %1$d h %2$d m Svisle - Pokaždý se ptát na cílovou složku + Pokaždé se ptát na cílovou složku Zobrazit menu Označit vybranou mangu jako kompletně přečtenou? \n @@ -654,13 +654,13 @@ Odepnout Server přetížen. Zkuste to za %s %1$d m %2$d s - Nesprávná proxy konfigurace - Plugin error:%s\nUjisti se, že používáš poslední verzi Kotatsu a pluginu + Nesprávná konfigurace proxy + Plugin error:%s\n Ujistěte se, že používáte nejnovější verzi Kotatsu a pluginu Procent zbývá Nekompatibilní plugin nebo vnitřní chyba. Ujistěte se, že používáte nejnovější verzi pluginu a aplikace Kotatsu. Externí zdroj/plugin Opakovat - Připojení je OK + Připojení je v pořádku Nedávné zdroje Preferovaný server pro média Oříznout stránky @@ -671,4 +671,125 @@ Procenta přečtených Kapitol přečtených Kapitol zbývajících - \ No newline at end of file + Povolí možnost filtrovat seznamy mang určitými parametry + Tato funkce je experimentální. Vytvořte si zálohu před tím, než ji zkusíte + Manhwa + Nedávno přidané + Populární tento měsíc + Populární tento rok + Původní jazyk + Rok + Josei + Manhua + Přidáno před dlouhou dobou + Populární tuto hodinu + Seinen + Země + Populární dnes + Populární tento týden + Shounen + Shoujo + Roky + Jakýkoliv + Všechny + Vždy se zeptat + SFW + Testovací zpráva + Román + Neplatný formát: očekávan obrázek, ale získán %s + Zadejte ID chatu, kam by měly být odeslány zálohy + Cílový adresář + Manga \"%1$s\" (%2$s) nahrazena \"%3$s\" (%4$s) + Tato funkce bude hledat alternativní zdroje pro vybranou mangu. Tento proces potrvá nějaký čas a bude pokračovat na pozadí + Povolit všechny zdroje mang + Všechny dostupné zdroje mangy budou trvale povoleny + Automaticky mazat soubory starých záloh pro ušetření místa na úložišti + Vzestupně + Doujinshi + Uložit vybranou mangu? Toto může využít připojení a místo na disku + Povolit stahování přes mobilní data? + Tento zdroj nepodporuje hledání s filtry. Filtry byly vymazány + Připojení bylo resetováno vzdáleným hostitelem + Záloha bude obnovena na pozadí + Obnovování zálohy + Zobrazit posuvník + One shot + Zkontrolovat, jestli API funguje + Chat ID není nastaveno + Otevřít Telegram bota + Není v oblíbených + Nepopulární + Nízké hodnocení + Sestupně + Podle Data + Popularita + Stahování na pozadí + Stáhnout nové kapitoly + Manga se staženými kapitolami + Ladit + Stahování přidáno + Více možností + Tento zdroj potřebuje pro pokračování vyřešení captcha + Aktualizováno dávno + Přihlašte se do %s abyste mohli pokračovat + Pro \"%s\" nebyly nalezeny žádné alternativy + Telegramová slupina + Povolit vždy + Pouze tentokrát + Na šířku + Smazat staré zálohy + Všechny zdroje jsou povoleny + Hodnocení + Zdroj + Anonymní + Průhledný informační proužek pro čtenáře + Spravovat odkazy + Zpracování odkazů na manga z externích aplikací (např. webového prohlížeče). Může být také nutné povolit ji ručně v systémových nastaveních aplikace + Email + Umělec CG + Sada obrázků + Hra CG + Zdrojový kód + Uživatelský manuál + Můžete vybrat kapitoly ke stažení dlouhým stisknutím na ně v listu kapitol. + Otočení obrazovky + Na výšku + Žánr + Přístup odepřen (403) + Maximální počet záloh + Pro \"%s\" není potřeba oprava + Vyčistit databázi + Odstranění nepoužívaných informací o manze + Kodomo + Poslat zálohy do Telegramu + Zkontrolovat připojení + Klikněte pro otevření chatu s Kotatsu Zálohovacím botem + Formát obrázku není podporován %s + Překlad + Ovládací prvky čtečky na spodním panelu + Kapitoly a stránky + Posuvník přepínání stránek + Otáčení obrazovky bylo uzamčeno + Otáčení obrazovky bylo odemčeno + Opravování mangy + Úspěšně opraveno + Začít stahování + Uložit mangu + Zastaveno + Stahování pomocí mobilních dat + Přeskočit vše + Autor + Nepovolit + Přihlaste se do %s a nastavte integraci. To vám umožní sledovat postup a stav čtení mangy + Telegram chat ID + Můžete poslat hlášení o chybě vývojářům. Tímto nám pomůžete vyřešit tento problém. + Vyhledat ve vypnutých zdrojích + Svazek %1$s Kapitola %2$s + Kapitola %s + Nepojmenovaná kapitola + Detaily o chybě + Zkuste otevřít mangu ve webovém prohlížeči, abyste zjistliti jestli je dostupná. + Vypadá to, že vaše verze Kotatsu je zastaralá. Prosíme, nainstalujte nejnovější verzi pro získání všech dostupných oprav chyb. + Vypnout oznámení o captcha + Nebudete dostávat oznámení o řešení CAPTCHA pro tento zdroj, ale to může vést k rozbití operací na pozadí (hledání nových kapitol, získávání doporučení atd) + diff --git a/app/src/main/res/values-de/strings.xml b/app/src/main/res/values-de/strings.xml index dd7200b4e..258cdee22 100644 --- a/app/src/main/res/values-de/strings.xml +++ b/app/src/main/res/values-de/strings.xml @@ -148,7 +148,7 @@ Löschen Importieren Bild teilen - Gespeichert + Seite gespeichert Seite speichern Löschen Lösen @@ -628,4 +628,10 @@ Erneut versuchen %d s Es gibt keine Berechtigung für den Zugriff auf Manga auf externem Speicher + Häufigkeit der Überprüfung + Such Vorschläge + reparieren + Kürzliche Suchen + Vorgeschlagene Suchen + Autoren \ No newline at end of file diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5ef4be1cf..07caa2909 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -753,4 +753,20 @@ Gestionar enlaces Gestionar enlaces de manga desde aplicaciones externas (por ejemplo, navegador web). También puede ser necesario habilitarlo manualmente en la configuración de la aplicación Esta fuente requiere resolver un captcha para continuar + Incógnito + Identificación del chat de Telegram + ¡La copia de seguridad de Kotatsu en Telegram está funcionando! + El ID del chat no está configurado + Comprueba si la API funciona + Conexión restablecida por el host remoto + Mostrar control deslizante + Abre el bot de Telegram + Traducción + Autor + Calificación + Fuente + Prueba de la conexión + Presione para abrir el chat con Kotatsu Backup Bot + Enviar copias de seguridad por Telegram + Ingrese el ID del chat donde se deben enviar las copias de seguridad \ No newline at end of file diff --git a/app/src/main/res/values-et/strings.xml b/app/src/main/res/values-et/strings.xml index 8539c095d..705d51a30 100644 --- a/app/src/main/res/values-et/strings.xml +++ b/app/src/main/res/values-et/strings.xml @@ -385,7 +385,7 @@ Eemaldatud lemmikutest Järjehoidja Näita kõik - Salvestatud + Leht on salvestatud Täna Pole lemmikkategooriaid Kehtetu domeen @@ -399,7 +399,7 @@ Parool Andmed ja privaatsus Kehtetu väärtus - Allalaadimised on peatatud + Allalaadimised on katkestatud WebView ei ole saadaval: kontrollige kas WebView pakkuja on installitud Port Tüüp @@ -407,7 +407,7 @@ Kasutaja nimi Autoriseerimine (valikuline) Allalaadimised on pausil - Kõik pooleliolevad allalaadimised peatatakse ja poolenisti allalaaditud sisu kustutatakse + Kõik pooleliolevad allalaadimised katkestatakse ja poolenisti allalaaditud sisu kustutatakse Sul pole midagi allalaetud Vigane pordi number Luba sisse suumimis liigutusi webtooni moodis @@ -516,4 +516,28 @@ See manga Andmeallikate kataloog Varukoopia kuupäev: %s - \ No newline at end of file + Hinnang sisule + Lehed on salvestatud + Olek + Eemalda ajaloost + Lugemise statistika + Peatatud + Kustuta statistika + Statistika on kustutatud + Statistika + Vähenda mälukasutust (beeta) + Täisekraanivaade + Peida süsteemi oleku- ja liikumisribad + Saadaval: %1$d + Mälukasutuse vähendamiseks alanda lehtede kuvamise kvaliteeti + Välista žanrid + Näita hinnangulist lugemiseks kuluvat aega + Kas sa kindlasti soovid statistikat kustutada? Seda tegevust ei saa tagasi pöörata. + Peatükid ja lehed + Lehevahetuse liugurlüliti + Ekraani pööramise võimalus on lukustatud + Ekraani pööramise võimaluse lukustatus on eemaldatud + Vaikimisi vahekaart + Ajahinnang ei pruugi olla täpne + Kataloog + diff --git a/app/src/main/res/values-fi/strings.xml b/app/src/main/res/values-fi/strings.xml index 4e4d116a5..9441f0a2e 100644 --- a/app/src/main/res/values-fi/strings.xml +++ b/app/src/main/res/values-fi/strings.xml @@ -110,7 +110,7 @@ Jatka Vaihda sivuja Lukijan asetukset - Haluatko todella poistaa ”%s” puhelimen paikallisesta tallennustilasta\? + Haluatko todella poistaa ”%s” puhelimen paikallisesta tallennustilasta? Poista manga Hae kohteesta %s Ruudukon koko diff --git a/app/src/main/res/values-fil/strings.xml b/app/src/main/res/values-fil/strings.xml index 7c2ac0991..add9e1f8a 100644 --- a/app/src/main/res/values-fil/strings.xml +++ b/app/src/main/res/values-fil/strings.xml @@ -27,8 +27,8 @@ Mga setting sa reader Magpalit ng (mga) pahina Magpatuloy - I-clear ang cache ng mga thumbnail - Na-clear + Linisin ang cache ng mga thumbnail + Nalinisan na Available ang isang bagong bersyon ng app Buksan sa web browser Mga abiso @@ -50,8 +50,8 @@ Mga update Mga resulta ng paghahanap Laki: %s - I-clear ang feed ng mga update - Na-clear + Linisin ang feed ng mga update + Nalinisan na Update Ang pag update ng feed ay magsisimula sa lalong madaling panahon Maghanap ng mga update @@ -75,8 +75,8 @@ Ngayong araw I-tap para subukang muli Lutasin - Inalis ang lahat ng mga cookie - I-clear ang feed + Natanggal ang lahat ng mga cookie + Linisin ang feed Suriin ang mga bagong kabanata Mag-sign in Mag-sign in upang tingnan ang nilalamang ito @@ -133,12 +133,12 @@ Pangalan Sikat Mga pahina - I-clear ang kasaysayan - I-clear ang kasaysayan ng paghahanap + Linisin ang kasaysayan + Linisin ang kasaysayan ng paghahanap Bagong bersyon: %s Hindi tumutugma sa mga password - I-clear ang mga cookie - I-clear ang page cache + Linisin ang mga cookie + Linisin ang page cache I-download Mga setting ng abiso Tunog ng abiso @@ -171,7 +171,7 @@ Isalin ang app na ito Awtorisado na Kinakailangan ang CAPTCHA - I-clear nang permanente ang lahat ng update history\? + Linisin nang permanente ang lahat ng update history? Maglagay ng password para simulan ang app Ang ilang device ay may iba\'t ibang gawi ng system, na maaaring masira ang mga gawain sa background. Nakapila na @@ -193,7 +193,7 @@ Kabanata %1$d ng %2$d Subukan ulit Pag-aayos ng order - I-clear + Linisin Lagi na lang I-preload ang mga pahina Naka-log in bilang %s @@ -234,7 +234,7 @@ Ipakita ang porsyento na nabasa sa kasaysayan at mga paborito Ipakita lahat Pumili ng saklaw - I-clear ang lahat ng kasaysayan + Linisin lahat ng kasaysayan Maaari kang lumikha ng bookmark habang nagbabasa ng manga Tinanggal ang mga bookmark Random @@ -274,7 +274,7 @@ Walang natitirang espasyo sa device Pag-zoom sa webtoon Server side error (%1$d). Subukang muli mamaya - I-clear din ang impormasyon tungkol sa mga bagong kabanata + Linisan rin ang impormasyon tungkol sa mga bagong kabanata Preloading ng nilalaman Markahan bilang kasalukuyan Wika @@ -296,7 +296,7 @@ Maaaring makatulong sa kaso ng ilang mga isyu. Ang lahat ng pahintulot ay mawawalan ng bisa Imbalidong domain Huling 2 oras - Nabura ang kasaysayan + Nalinisan na ang kasaysayan Pamahalaan Wala pang bookmark 18+ @@ -411,7 +411,7 @@ Ipakita ang kasalukuyang oras at progreso ng pagbabasa sa tuktok ng screen Ipakita ang mga numero ng pahina sa ibabang gilid Imbalidong numero ng port - I-clear ang mga cookie para sa tinukoy na domain lamang. Sa karamihan ng mga kaso, magpapawalang-bisa ang awtorisasyon + Linisin ang mga cookie para sa tinukoy na domain lamang. Sa karamihan ng mga kaso, magpapawalang-bisa ang awtorisasyon Lahat ng mga kabanata na may pagsasalin na %s Ang buong manga Unang %s @@ -577,12 +577,12 @@ Statistika sa pagbabasa Wala pang isang minuto Mga statistika - I-clear ang statistika - Na-clear ang statistika + Linisin ang statistika + Nalinisan na ang statistika Mga nabasang pahina: %s Ibang manga Araw - Gusto mo ba talagang i-clear ang lahat ng istatistika ng pagbabasa? Ang gawaing ito ay hindi pwedeng baguhin. + Gusto mo ba talagang linisin ang lahat ng istatistika ng pagbabasa? Ang gawaing ito ay hindi pwedeng baguhin. Linggo Lahat ng oras Buwan @@ -705,7 +705,7 @@ Taon Mga Taon Kahit ano - Ang source na ito ay hindi sinusuportahan ang paghahanap na may mga filter. Ang iyong mga filter ay na-clear + Ang source na ito ay hindi sinusuportahan ang paghahanap na may mga filter. Ang iyong mga filter ay malilinisan Demograpiko Set ng mga imahe Mag-debug @@ -753,4 +753,45 @@ Pangasiwaan ang manga link mula sa mga panlabas na application (hal. web browser). Maaaring kailanganin mo rin itong manual na paganahin sa mga setting ng system ng aplikasyon Ang email Ang source na ito ay kinakailang lutasin ang captcha para magpatuloy - \ No newline at end of file + Ipakita ang slider + May-Akda + I-reset ang koneksyon sa remote host + Nagtatago + I-check kung ang API ay gumagana + Mensaheng Pinasusubok + Ang chat ID ay hindi nakatakda + Ang telegram chat ID + Buksan ang bot sa telegram + Linisin ang database + Tanggalin ang impormasyon tungkol sa manga na hindi ginamit + Magpadala ng mga backup sa Telegram + I-test ang koneksyon + Pindutin para buksan ang pinag-usapan gamit ang Kotatsu Backup Bot + Pagsasalin + Ilagay ang chat ID kung saan dapat ipadala ang mga backup + Kaurian + Pinagmulan + Lahat ng available na manga source ay permanenteng papaganahin + Lahat ng source ay napagana na + Paganahin lahat ang mga manga source + Ang pag-ikot ng screen ay na-unlock + Ini-restore ang backup sa background + Niri-restore ang backup + Mga kontrol ng reader sa bottom bar + Mga Kabanata at pahina + Ang pag-ikot ng screen ay naka-lock + Slider sa pagpalit ng pahina + Global na paghahanap + Maghanap kahit saan + Mga badge sa listahan + I-disable ang mga abiso ng captcha + Hindi ka makakatanggap ng mga abiso tungkol sa paglutas ng CAPTCHA para sa source na ito ngunit maaari itong humantong sa pagsira sa mga operasyon sa background (pagsusuri ng mga bagong kabanata, pagkuha ng mga rekomendasyon, atbp) + Detaye ng error + Subukang buksan ang manga sa isang web browser upang matiyak na available ito sa source. + Maghanap sa pamamagitan ng mga hindi pinaganang source + Vol %1$s Kabanata %2$s + Kabanata %s + Walang pangalan na kabanata + Mukhang luma na ang bersyon mo ng Kotatsu. Mangyaring i-install ang pinakabagong bersyon upang makuha ang lahat ng magagamit na mga pag-aayos. + Maaari kang magsumite ng ulat ng bug sa mga developer. Makakatulong ito sa amin na magsiyasat at ayusin ang isyu. + diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml index 16e75073e..d0c77dce7 100644 --- a/app/src/main/res/values-fr/strings.xml +++ b/app/src/main/res/values-fr/strings.xml @@ -107,7 +107,7 @@ Continuer Changer de pages Paramètres du lecteur - Supprimer « %s » de l\'appareil de façon permanente ? + Supprimer \"%s\" de l\'appareil de façon permanente ? Supprimer le manga Rechercher sur %s Taille de la grille @@ -124,7 +124,7 @@ Partager l\'image Page sauvegardée Sauvegarder la page - « %s » supprimé du stockage local + \"%s\" supprimé du stockage local Retirer Effacer Pages @@ -134,7 +134,7 @@ Thème Filtre Ordre de tri - Évaluation + Note Le plus récent Mis à jour Populaire @@ -270,7 +270,7 @@ Sélectionner une plage Contenu non trouvé ou supprimé Détails de l\'erreur:<br><tt>%1$s</tt><br><br>1. Essayez d\'<a href=%2$s>ouvrir le manga dans un navigateur web</a> pour vous assurer qu\'il est disponible sur sa source<br>2. Assurez-vous que vous utilisez la <a href=kotatsu://about>dernière version de Kotatsu</a><br>3. Si elle est disponible, envoyez un rapport d\'erreur aux développeurs. - Rappuyez sur Retour pour quitter + Appuyez à nouveau sur Retour pour quitter Êtes-vous sûr(e) de vouloir supprimer les catégories de favoris sélectionnées ?\nTous les mangas qui s\'y trouvent seront perdus et ceci ne peut pas être annulé. Appuyez deux fois sur la touche Retour pour quitter l\'appli Disponible @@ -511,9 +511,9 @@ Connectez-vous pour synchroniser le compte Passer Restaurer - Note du contenu + Classification du contenu Exclure les genres - Sûr + Tout public Adulte Onglet par défaut Ces paramètres peuvent être appliqué globalement ou uniquement au manga actuel. S\'il est activé globalement il ne remplacera les paramètres individuel. @@ -600,7 +600,7 @@ Afficher les miniatures des pages Filtré par traductions Le plus ancien - Lues il y a longtemps + Lu il y a longtemps Non lu Afficher les chapitres avec des traductions différentes séparément, au lieu d\'une seule liste Activer la source @@ -696,9 +696,9 @@ Réparation du manga Correction réussie Cette fonction trouvera d\'autres sources pour le manga sélectionné. La tâche prendra du temps et se déroulera en arrière-plan - Pas de correction requise pour « %s » - Aucune alternative trouvée pour « %s » - Manga « %1$s » (%2$s) remplacé par « %3$s » (%4$s) + Pas de correction requise pour \"%s\" + Aucune alternative trouvée pour \"%s\" + Manga \"%1$s\" (%2$s) remplacé par \"%3$s\" (%4$s) Externe/module d\'extension Commencer à télécharger Enregistrer le manga sélectionné ? Ceci peut consommer des données et de l\'espace disque @@ -750,4 +750,51 @@ Courriel Gérer les liens vers des mangas à partir d\'applications externes (par exemple, un navigateur web). Il se peut que vous deviez également l\'activer manuellement dans les paramètres système de l\'application Cette source nécessite la résolution d\'un captcha pour continuer - \ No newline at end of file + Auteur + Source + Traduction + Vérifier si l\'API fonctionne + Ouvrir le bot Telegram + Mode Incognito + ID de chat Telegram + Message de test + Tester la connexion + Appuyez pour ouvrir le chat avec le bot de sauvegarde Kotatsu + Connexion réinitialisée par l\'hôte distant + L\'ID du chat n\'est pas défini + Entrez l\'ID du chat où les sauvegardes doivent être envoyées + Envoyer les sauvegardes sur Telegram + Afficher le curseur + Effacer la base de données + Supprimer les informations sur les mangas qui ne sont pas utilisées + Note + Toutes les sources de mangas disponibles seront activées de façon permanente + Toutes les sources sont activées + Activer toutes les sources de mangas + La sauvegarde sera restaurée en arrière-plan + Restauration de la sauvegarde + Détails de l\'erreur + Essayez d\'ouvrir le manga dans un navigateur Web pour vous assurer qu\'il est disponible sur sa source. + Il semble que votre version de Kotatsu soit obsolète. Veuillez installer la dernière version pour obtenir tous les correctifs disponibles. + Vous pouvez envoyer un rapport des erreurs aux développeurs. Cela nous aidera à enquêter sur le problème et à le résoudre. + Rechercher dans les sources désactivées + Barre d\'informations transparente pour le lecteur + Vol %1$s chapitre %2$s + Chapitre %s + Chapitre sans nom + Commandes du lecteur dans la barre inférieure + Chapitres et pages + Curseur de changement de page + La rotation de l\'écran a été verrouillée + La rotation de l\'écran a été déverrouillée + Lien vers le manga sur %s + Lien vers le manga dans Kotatsu + Simple + Désactiver les notifications captcha + Vous ne recevrez pas de notifications concernant la résolution du CAPTCHA pour cette source, mais cela peut entraîner l\'interruption des opérations en arrière-plan (vérification de nouveaux chapitres, obtention de recommandations, etc) + Recherche globale + Chercher partout + Badges dans les listes + N\'a pas la permission d\'écrire un fichier + Effacer les données du navigateur + diff --git a/app/src/main/res/values-hr/strings.xml b/app/src/main/res/values-hr/strings.xml index 2bd3dbb7f..3284a5cc8 100644 --- a/app/src/main/res/values-hr/strings.xml +++ b/app/src/main/res/values-hr/strings.xml @@ -690,8 +690,8 @@ Kodomo Stranice su spremljene Nestabilna funkcija - „%s” ne zahtijeva popravljanje - Za „%s” nisu pronađene alternative + %s ne zahtijeva popravljanje + Za %s nisu pronađene alternative Popularno u ovom satu Popularno danas Popularno ovaj mjesec @@ -744,4 +744,43 @@ Uspješno popravljeno Ova funkcija pronalazi alternativne izvore za odabranu mangu. Zadatak će potrajati i odvijat će se u pozadini Odredišni direktorij - \ No newline at end of file + Udaljeni host je resetirao vezu + Prikaži klizač + Inkognito + Probna poruka + Chat ID nije postavljen + Provjeri je li API radi + Otvori Telegram bot + Ovaj izvor zahtijeva rješavanje captcha zadatka za nastavljanje + E-mail + Izbriši bazu podataka + Izbriši informacije o mangama koje se ne koriste + Provjeri vezu + Pošalji sigurnosne kopije u Telegram + Upiši ID chata na koji treba slati sigurnosne kopije + Pritisni za otvaranje chata s Kotatsu Backup Bot + Prijevod + Ocjena + Izvor + Autor + Telegram chat ID + Sigurnosna kopija će se obnoviti u pozadini + Obnavljanje sigurnosne kopije + Nećeš primati obavijesti o rješavanju CAPTCHA za ovaj izvor, ali to može prouzročiti prekid pozadinskih operacija (provjeravanje novih poglavlja, dobivanje preporuka itd.) + Transparentna traka s informacijama čitača + Aktiviraj sve manga izvore + Svi manga izvori će biti trajno aktivirani + Svi izvori su aktivirani + Barataj poveznicama + Barataj manga poveznicama iz eksternih aplikacija (npr. web preglednik). Možda ćeš to također morati ručno aktivirati u postavkama sustava aplikacije + Jednostavno + Kontrole čitača u donjoj traci + Poglavlja i stranice + Klizač za listanje stranica + Okretanje ekrana je zaključano + Okretanje ekrana je otključano + Deaktiviraj Captcha obavijesti + Globalna pretraga + Traži svuda + Značke u popisima + diff --git a/app/src/main/res/values-in/strings.xml b/app/src/main/res/values-in/strings.xml index 37900cb3c..884ce12f9 100644 --- a/app/src/main/res/values-in/strings.xml +++ b/app/src/main/res/values-in/strings.xml @@ -27,7 +27,7 @@ Buat pintasan… Bagikan %s Cari - Cari komik + Cari manga Mengunduh… Memproses… Diunduh @@ -53,7 +53,7 @@ Tidak ada deskripsi \"%s\" dihapus dari penyimpanan Simpan halaman - Disimpan + Halaman disimpan Bagikan gambar Impor Bersihkan singgahan halaman @@ -61,12 +61,12 @@ Standar Ukuran kisi Cari di %s - Hapus komik + Hapus manga Hapus \"%s\" dari perangkat secara permanen? Pengaturan pembaca Ganti halaman Bab - Daftar + Daftari Daftar rinci Webtoon Mode baca @@ -77,12 +77,12 @@ Domain Versi baru aplikasi tersedia Buka di peramban web - Pemberitahuan + Notifikasi %1$d dari %2$d diaktifkan Bab baru Unduh - Pengaturan pemberitahuan - Suara pemberitahuan + Pengaturan notifikasi + Suara notifikasi Sepi juga di sini… Apa yang Anda baca akan ditampilkan di sini Cari sesuatu untuk dibaca via «Eksplor» @@ -121,7 +121,7 @@ Kelompok Hari ini Ketuk untuk coba lagi - Konfigurasi yang dipilih akan diingat untuk komik ini + Konfigurasi yang dipilih akan diingat untuk manga ini Diam CAPTCHA diperlukan Selesaikan @@ -138,7 +138,7 @@ Cadangan disimpan Beberapa perangkat mempunyai perilaku sistem yang berbeda, yang mungkin akan merusak tugas latar belakang. Baca lebih lanjut - Bab ini menghilang + Bab ini tidak ada Terjemahan Masuk pada %s tidak didukung Anda akan keluar dari semua sumber @@ -146,31 +146,31 @@ Tamat Masih lanjut Bawaan - Kecualikan komik NSFW dari riwayat + Kecualikan manga TAUSB dari riwayat Nomor halaman Kebijakan tangkapan layar Bolehkan - Blokir NSFW + Blokir TAUSB Selalu blokir Saran Aktifkan saran Sarankan manga berdasarkan preferensi Anda Semua data hanya dianalisis secara lokal pada perangkat ini dan tidak pernah dikirim ke mana pun. - Mulai membaca komik dan Anda akan mendapatkan saran yang dipersonalisasi - Jangan menyarankan komik NSFW + Mulai membaca manga dan Anda akan mendapatkan saran yang dipersonalisasi + Jangan menyarankan manga TAUSB Selalu Muat ulang halaman Masuk sebagai %s Diaktifkan Dinonaktifkan Atur ulang filter - Pilih bahasa komik yang ingin Anda baca. Anda bisa mengubahnya nanti di pengaturan. + Pilih bahasa manga yang ingin Anda baca. Anda bisa mengubahnya nanti di pengaturan. Jangan Pernah Hanya di Wi-Fi 18+ Berbagai bahasa Cari bab - Tidak ada bab di komik ini + Tidak ada bab di manga ini %1$s%% Tampilan Kecualikan genre @@ -182,11 +182,11 @@ Membantu menghidari pemblokiran alamat IP Anda Bab akan dihapus di latar belakang Sembunyikan - Tersedia sumber komik baru + Tersedia sumber manga baru Periksa bab baru dan beri tahu tentang itu - Anda akan menerima pemberitahuan tentang pembaruan komik yang Anda baca - Anda tidak akan menerima pemberitahuan, tapi bab baru akan disorot di daftar - Aktifkan pemberitahuan + Anda akan menerima notifikasi tentang pembaruan manga yang Anda baca + Anda tidak akan menerima notifikasi, tapi bab baru akan disorot di daftar + Aktifkan notifikasi Nama Sunting Sunting kategori @@ -200,7 +200,7 @@ Dihapus dari riwayat Mode bawaan Otomatis deteksi mode pembaca - Secara otomatis mendeteksi jika komik itu webtoon + Otomatis mendeteksi jika manga adalah webtoon Kategori baru Indikator LED Getaran @@ -254,13 +254,13 @@ Ditunda Domain tak sah Belum ada markah - Anda bisa membuat markah ketika membaca komik + Anda bisa membuat markah ketika membaca manga Markah dihapus - Tidak ada sumber komik + Tidak ada sumber manga Acak Kosong Tampilkan bilah informasi di pembaca - Mengimpor komik + Mengimpor manga 2 jam terakhir Tampilkan semua Riwayat dihapus @@ -289,26 +289,25 @@ Simpan atau buang perubahan yang belum disimpan? Buang Gunakan biometrik jika tersedia - Komik dari favorit Anda - Komik yang baru-baru ini Anda baca + Manga dari favorit Anda + Manga yang baru-baru ini Anda baca Penghapusan data Akun sudah ada Kembali Membantu pemeriksaan pembaruan di latar belakang Ada sesuatu yang salah. Mohon untuk mengirim laporan kutu ke pengembang untuk membantu kami memperbaikinya. Lapor - Komik yang ditandai sebagai NSFW tidak akan ditambahkan ke riwayat dan progres Anda tidak akan disimpan + Manga yang ditandai sebagai TAUSB tidak akan ditambahkan ke riwayat dan progres Anda tidak akan disimpan Bisa membantu dalam beberapa masalah. Seluruh otorisasi akan menjadi tidak valid Kelola - Aktifkan sumber komik untuk membaca komik daring - Yakin ingin menghapus kategori favorit yang dipilih\? -\nSemua komik di dalamnya akan hilang dan ini tidak dapat dibatalkan. + Aktifkan sumber manga untuk membaca manga daring + Yakin ingin menghapus kategori favorit yang dipilih? \nSemua manga di dalamnya akan hilang dan ini tidak dapat dibatalkan. Atur Ulang - Komik tersimpan + Manga tersimpan Tekan Kembali lagi untuk keluar Tidak ada bab - Tampilkan pintasan komik baru-baru ini - Buat komik baru-baru ini tersedia dengan menekan panjang pada ikon aplikasi + Tampilkan pintasan manga baru-baru ini + Buat manga baru-baru ini tersedia dengan menekan panjang pada ikon aplikasi Pilih rentang Matikan semua DNS over HTTPS @@ -319,21 +318,21 @@ Pramuat konten %s - %s Tidak ada apapun di sini - Mengetuk tepi kanan, atau menekan tombol kanan, akan selalu beralih ke halaman berikutnya. + Jangan sesuaikan arah pindah halaman ke mode pembaca, mis. menekan tombol kanan selalu pindah ke halaman selanjutnya. Opsi ini hanya berdampak pada perangkat dengan tombol masukan perangkat keras Sumber dinonaktifkan Tandai sebagai saat ini Tampilkan konten yang mencurigakan - Untuk melacak progres membaca, pilih Menu → Lacak di layar detail komik. + Untuk melacak progres membaca, pilih Menu → Lacak di layar detail manga. Layanan - Juga informasi yang jelas tentang bab baru - Nyalakan Wi-Fi atau jaringan seluler untuk membaca komik daring + Juga bersihkan informasi tentang bab baru + Nyalakan Wi-Fi atau jaringan seluler untuk membaca manga daring Tajuk Agen Pengguna Mulai ulang aplikasi untuk menerapkan perubahan ini Kanade Bagikan log Tidak ada ruang tersisa di perangkat Izinkan pembaruan yang tidak stabil - Terima pemberitahuan tentang build yang tidak stabil + Terima notifikasi tentang build yang tidak stabil Unduhan dimulai Tampilkan indikator progres membaca Tampilkan persentase baca dalam riwayat dan favorit @@ -351,7 +350,7 @@ Mion Rikka Sakura - Pemrosesan komik tersimpan + Pemrosesan manga tersimpan Nonaktifkan pengoptimalan baterai Kontrol pembaca ergonomis Koreksi warna @@ -360,7 +359,7 @@ Jaringan tidak tersedia Oke Ketuk dan tahan item untuk menyusun ulang - Anda dapat memilih satu atau beberapa berkas .cbz atau .zip, setiap file akan dikenali sebagai komik terpisah. + Anda dapat memilih satu atau beberapa berkas .cbz atau .zip, setiap file akan dikenali sebagai manga terpisah. Anda dapat memilih direktori yang berisi arsip atau gambar. Setiap arsip (atau subdirektori) akan dikenali sebagai sebuah bab. Kecepatan Tampilkan di Rak @@ -386,7 +385,7 @@ Terkadang menampilkan notifikasi dengan manga yang disarankan Lebih Semua unduhan aktif akan dibatalkan, data yang sudah terunduh sebagian akan hilang - Riwayat unduhan Anda akan dihapus secara permanen + Riwayat unduhan Anda akan dihapus secara permanen. Berkas yang diunduh tidak akan terpengaruh Anda tidak memiliki unduhan apa pun Unduhan telah dilanjutkan Unduhan telah dijeda @@ -424,18 +423,18 @@ Pilih direktori khusus Anda tidak memiliki akses ke file atau direktori ini Direktori manga - Matikan NSFW + Nonaktifkan TAUSB Deskripsi Atur kategori Tampilkan - Tampilkan daftar komik terkait. Bisa jadi tidak akurat atau hilang + Tampilkan daftar manga terkait. Bisa jadi tidak akurat atau hilang Jangan perbarui saran dengan koneksi jaringan internet terbatas Jangan perbarui bab baru dengan koneksi jaringan internet terbatas - Masukkan judul, genre, atau nama sumber komik + Masukkan judul, genre, atau nama sumber manga Progres Ditambahkan Terlalu banyak permintaan. Coba lain kali - Komik terkait + Manga terkait Bulan ini Pencarian suara Gelap @@ -444,7 +443,7 @@ Hitam Latar belakang Data tidak dikembalikan - Pastikan kamu memilih berkas pencadangan yang tepat + Pastikan Anda memilih berkas cadangan yang tepat %s memerlukan penyelesaian Chaptcha agar dapat diakses Bahasa Tidak diketahui @@ -464,7 +463,7 @@ Varian daring Biarkan Layar Menyala Perkecil - Jangan Matikan Layar Saat Membaca Komik + Jangan matikan layar saat membaca manga Opsi daftar Apakah menampilkan tombol kontrol zum di sudut kanan bawah Frekuensi pembuatan cadangan @@ -486,19 +485,19 @@ Prompt untuk mengaktifkan sumber baru yang ditambahkan setelah memperbarui aplikasi Sumber katalog Aman - Komik - Komik Dewasa + Manga + Hentai Filter berdasarkan genre dan status tidak didukung untuk sumber ini Filter bedasarkan genre dan lokal tidak didukung oleh sumber ini Komik Katalog - Silahkan pilih sumber konten komik mana yang akan diaktifkan. konfigurasi ini dapat dilakukan nanti di pengaturan + Silakan pilih sumber konten yang akan diaktifkan. Bisa dilakukan nanti di pengaturan Kecualikan genre Kurangi konsumsi memori (beta) Terapkan Kembalikan Kelola sumber - Tidak ada sumber komik yang tersedia berdasarkan query yang kamu masukan + Tidak ditemukan sumber manga berdasarkan kueri Anda Ketik nama genre Secara global Dewasa @@ -506,11 +505,11 @@ Manga ini Kunci rotasi layar Lewati - Pencarian tidak didukung untuk sumber komik ini + Pencarian tidak didukung untuk sumber manga ini Mendatang Pengaturan ini dapat diterapkan secara menyeluruh atau hanya pada manga saat ini. Jika diterapkan secara menyeluruh, pengaturan pada manga tidak akan ditimpa. Sumber yang diaktifkan - Matikan sumber NSFW and sembunyikan komik dewasa dari daftar jika memungkinkan + Nonaktifkan sumber TAUSB and sembunyikan manga dewasa dari daftar jika memungkinkan Peringkat konten Tanggal dicadangkan %s Tersedia:%1$d @@ -527,7 +526,7 @@ Tidak ada sumber yang tersedia di bagian ini, atau semuanya mungkin sudah ditambahkan. \nPantau terus Tandai telah selesai - Mungkinn membantu jika kamu mendapatkan masalah saat memulai pengunduhan + Mungkin membantu dengan memulai pengunduhan jika Anda memiliki masalah dengannya Tandai manga yang dipilih telah selesai dibaca \n \nPeringatan : proses baca saat ini akan hilang. @@ -588,7 +587,7 @@ %1$d j %2$d m Direktori penyimpanan halaman default Bersihkan statistik - Hal ini akan menghapus secara permanen semua bab yang ditandai sebagai telah dibaca dari penyimpanan lokal Anda. Anda dapat mengunduh ulang nanti, tetapi bab yang diimpor mungkin akan hilang selamanya + Hal ini akan menghapus permanen semua bab yang ditandai telah dibaca dari penyimpanan lokal Anda. Anda dapat mengunduh ulang nanti, tetapi bab yang diimpor mungkin akan hilang selamanya Statistik membaca Manga lainnya Menampilkan gambar mini halaman @@ -602,7 +601,7 @@ Pilih file cadangan Kotatsu yang tepat Memperbaiki Tidak ada izin untuk mengakses manga di penyimpanan eksternal - Jangan tampilkan pemberitahuan tentang pembaruan manga NSFW + Jangan tampilkan notifikasi tentang pembaruan manga TAUSB Saran pencarian Pertanyaan terbaru Penulis @@ -610,13 +609,13 @@ Tidak ada statistik untuk periode yang dipilih Format unduhan yang disukai Migrasi selesai - Menghapus bab yang telah Anda baca dari penyimpanan lokal untuk mengosongkan ruang + Hapus bab yang telah Anda baca dari penyimpanan lokal untuk mengosongkan ruang Migrasi Manga migration - Menghapus bab yang sudah dibaca secara otomatis + Otomatis hapus bab yang sudah dibaca Berjalan saat aplikasi dimulai - Menghapus bab yang sudah dibaca - Tidak ada bab yang telah dihapus + Hapus bab yang sudah dibaca + Tidak ada bab yang dihapus Dibagi berdasarkan terjemahan Menampilkan bab dengan terjemahan yang berbeda secara terpisah, bukan dalam satu daftar Terlama @@ -629,7 +628,7 @@ Memeriksa log bab baru Menonaktifkan pemeriksaan konektivitas Lewati pemeriksaan konektivitas jika Anda mengalami masalah dengan konektivitas (misalnya, masuk ke mode offline meskipun jaringan tersambung) - Menonaktifkan notifikasi NSFW + Nonaktifkan notifikasi TAUSB Informasi debug tentang pemeriksaan latar belakang untuk bab baru Semua bahasa Blokir saat mode penyamaran @@ -641,11 +640,11 @@ Anda diblokir oleh server. Coba gunakan koneksi jaringan yang berbeda (VPN, Proxy, dll.) Lebih jarang Jangan sembunyikan bilah navigasi dan tampilan pencarian saat menggulir - Dihapus, dibersihkan + %1$s dihapus, %2$s dibersihkan Pertanyaan yang disarankan Sumber disematkan Sumber sumbver terbaru - Tidak ada manga yang cocok dengan filter yang kamu pilih + Tidak ada manga yang cocok dengan filter yang Anda pilih Server gambar yang dipilih Potong Halaman Sematkan @@ -655,9 +654,9 @@ Fitur tidak stabil Fungsi ini bersifat eksperimental. Pastikan Anda memiliki cadangan untuk menghindari kehilangan data Konfigurasi proxy tidak valid - Bab-bab yang dibaca + Bab yang dibaca Persen dibaca - Bab-bab yang tersisa + Bab yang tersisa Eksternal/plugin Sumber tidak disematkan Sumber disematkan @@ -673,7 +672,6 @@ Memberikan kemampuan untuk memfilter daftar manga berdasarkan parameter tertentu SFW Lewati semua - Terjebak Tidak di favorit Plugin yang tidak kompatibel atau kesalahan internal. Pastikan Anda menggunakan versi terbaru dari plugin dan Kotatsu Diperbarui sejak lama @@ -683,4 +681,97 @@ Turun Tanggal Popularitas - \ No newline at end of file + Baru ditambahkan + Populer hari ini + Populer tahun ini + Bahasa asli + Tahun + Tampilkan penggeser + Periksa apakah API bekerja + Tes pesan + ID chat Telegram + Halaman disimpan + Simpan manga yang dipilih? Ini akan mengkonsumsi data dan ruang penyimpanan + Perbaikan tidak diperlukan untuk \"%s\" + Simpan manga + Potret + Genre + Unduhan ditambahkan + Semua sumber manga yang tersedia akan diaktifkan permanen + Semua sumber diaktifkan + Surel + Pengunduhan latar belakang + Shounen + Seinen + Tahun + Set gambar + Mulai unduh + Mengunduh melalui jaringan seluler + Semua + Izinkan sekali + Orientasi layar + Selalu tanya + Lanskap + Akses ditolak (403) + Jumlah maks cadangan + Format tidak valid: seharusnya gambar tetapi %s + Hapus informasi tentang manga yang tidak digunakan + Bersihkan basisdata + Alternatif tidak ditemukan untuk \"%s\" + Tes koneksi + Kirim cadangan di Telegram + Masukkan ID chat untuk pengiriman cadangan + Tekan untuk membuka chat dengan Bot Cadangan Kotatsu + Hapus cadangan lama + Otomatis menghapus berkas cadangan lama untuk menghemat ruang penyimpanan + Awakutu + Selalu izinkan + Sumber ini tidak mendukung pencarian dengan filter. Filter Anda telah dikosongkan + Shoujo + Manga \"%1$s\" (%2$s) diganti dengan \"%3$s\" (%4$s) + Manhua + Manual pengguna + Direktori tujuan + Josei + Unduh bab baru + Novel + Manhwa + Kode sumber + Izinkan mengunduh melalui jaringan seluler? + Demografis + Grup telegram + Opsi lainnya + Populer minggu ini + Populer bulan ini + Jangan izinkan + Buka bot Telegram + Aktifkan semua sumber manga + ID chat belum diset + Sumber ini membutuhkan penyelesaian captcha untuk melanjutkan + Format gambar tidak didukung: %s + Terjemahan + Manga dengan bab terunduh + Berhasil diperbaiki + Fungsi ini akan mencari sumber alternatif untuk manga yang dipilih. Akan membutuhkan waktu sesaat dan dikerjakan di latar belakang + Memperbaiki manga + Anda bisa memilih bab untuk diunduh dengan menekan lama item di dalam daftar bab. + Nilai + Sumber + Bahasa + Daftar + Bahasa + Koneksi diatur ulang oleh host jarak jauh + Penyamaran + Kodomo + Satu tembakan + Film Doujinshi + Artis CG + Bilah informasi pembaca transparan + Menangani tautan + Menangani tautan manga dari aplikasi eksternal (misalnya browser web). Anda mungkin juga perlu mengaktifkannya secara manual di pengaturan sistem aplikasi. + Setiap + Pengarang + Pastikan Anda menggunakan plugin dan Kotatsu versi terbaru + Memulihkan Cadangan + Pencadangan akan dipulihkan di latar belakang + diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index ad8cceb9e..ae65d70c2 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -21,9 +21,9 @@ Popolare Per nome Scaricati - Scaricamento finito + Download finito Elaborazione… - Scaricamento di manga… + Downloading… Cerca manga Cerca Condividi %s @@ -48,9 +48,9 @@ Modalità elenco Griglia Elenco dettagliato - Vuoi davvero eliminare «%s» dalla memoria locale del tuo dispositivo? - «%s» eliminato dall\'archiviazione locale - Lista + Vuoi davvero eliminare \" %s\" dalla memoria locale del tuo dispositivo? + \"%s\" eliminato dall\'archiviazione locale + Liste Capitoli Dettagli Errore di connessione @@ -113,7 +113,7 @@ Altro spazio di archiviazione Impossibile trovare uno spazio di archiviazione disponibile Non disponibile - Cartella di scaricamento + Cartella download Animazione delle pagine Manga recenti Scaffale da manga @@ -218,7 +218,7 @@ Escludi generi Rimozione completata Eliminare gli elementi selezionati dal dispositivo in modo permanente\? - Rallentamento dello scaricamento + Download lento Elaborazione dei manga salvati I capitoli verranno rimossi in background Aiuta ad evitare il blocco del tuo indirizzo IP @@ -354,7 +354,7 @@ Miku Permetti aggiornamenti instabili Ricevi notifiche riguardo versioni instabili - Scaricamento iniziato + Download iniziato Sakura Lingue Ingrandire @@ -749,4 +749,56 @@ Debug Elimina i vecchi backup Elimina automaticamente i vecchi backup per liberare spazio nella memoria - \ No newline at end of file + Ripristino della connessione da parte dell\'host remoto + Mostra cursore + Incognito + Messaggio di test + L\'ID chat non è impostato + ID chat di Telegram + Apri il bot di Telegram + Gestione link + Gestisci i link ai manga da applicazioni esterne (ad esempio, dal browser web). Potrebbe essere necessario abilitarlo manualmente nelle impostazioni di sistema dell\'applicazione + Email + Pulisci database + Elimina le informazioni sui manga che non vengono utilizzate + Invia i backup a Telegram + Inserisci l\'ID della chat dove inviare i backup + Premi per aprire la chat con Kotatsu Backup Bot + Traduzione + Connessione test + Verifica se l\'API è funzionante + Autore + Sorgente + Questa sorgente richiede di risolvere un captcha per continuare + Valutazione + Tutte le fonti sono abilitate + Abilità tutte le fonti manga + Tutte le fonti manga disponibili saranno abilitate permanentemente + Barra informazione del lettore trasparente + Ripristinando il backup + Il backup verrà ripristinato in background + Controlli del lettore nella barra in basso + Rotazione schermo bloccata + Rotazione schermo sbloccata + Capitoli e pagine + Slider di cambio pagina + Semplice + Cerca ovunque + Badge in elenchi + Ricerca globale + Disabilita notifiche captcha + Non riceverai notifiche sulla risoluzione dei CAPTCHA per questa fonte, ma ciò potrebbe interrompere le operazioni in background (controllo di nuovi capitoli, ottenimento di raccomandazioni, ecc.) + Vol %1$s Capitolo %2$s + Ricerca tra le fonti disabilitate + Capitolo %s + Capitolo senza nome + Dettagli errore + Prova ad aprire il manga nel browser web per verificare che sia disponibile nella sua fonte. + Sembra che la tua versione di Kotatsu non sia aggiornata. Installa l\'ultima versione per ottenere tutte le correzioni disponibili. + Puoi inviare una segnalazione di bug agli sviluppatori. Questo ci aiuterà a indagare e a risolvere il problema. + Link al manga su %s + Link al manga su Kotatsu + Pulisci i dati del browser, come la cache e i cookie. Attenzione: l\'autorizzazione nelle fonti manga potrebbe diventare non valida + Pulisci dati del browser + Non ha l\'autorizzazione a scrivere un file + diff --git a/app/src/main/res/values-lt/strings.xml b/app/src/main/res/values-lt/strings.xml index 0f36187da..fa9ad6d2b 100644 --- a/app/src/main/res/values-lt/strings.xml +++ b/app/src/main/res/values-lt/strings.xml @@ -29,4 +29,4 @@ Ieškoti manga Manga šaltiniai Atsisiunčiama… - + \ No newline at end of file diff --git a/app/src/main/res/values-ml/plurals.xml b/app/src/main/res/values-ml/plurals.xml index 21c214bbd..0ff76b4d2 100644 --- a/app/src/main/res/values-ml/plurals.xml +++ b/app/src/main/res/values-ml/plurals.xml @@ -4,4 +4,8 @@ %1$d ഒരു പുതിയ അദ്ധ്യായം %1$d ഒരുപാട് പുതിയ അധ്യായങ്ങൾ - \ No newline at end of file + + %1$d മിനിറ്റ് + %1$d മിനിറ്റ് + + diff --git a/app/src/main/res/values-nb-rNO/strings.xml b/app/src/main/res/values-nb-rNO/strings.xml index d9840d40f..f6bb69fcf 100644 --- a/app/src/main/res/values-nb-rNO/strings.xml +++ b/app/src/main/res/values-nb-rNO/strings.xml @@ -47,7 +47,7 @@ En ny versjon av programmet er tilgjengelig Tømt Tøm miniatyrbildehurtiglager - Slett «%s» fra enheten for godt\? + Slett «%s» fra enheten for godt? Nettserie Forvalg Tøm sidehurtiglager diff --git a/app/src/main/res/values-ne/strings.xml b/app/src/main/res/values-ne/strings.xml index 661e5b71d..3fb019725 100644 --- a/app/src/main/res/values-ne/strings.xml +++ b/app/src/main/res/values-ne/strings.xml @@ -220,4 +220,22 @@ त्रुटि विवरण:<br> <tt>%1$s</tt><br><br> 1. <a href=%2$s>वेब ब्राउजरमा मंगा खोल्ने</a> प्रयास गर्नुहोस् कि यो यसको स्रोतमा उपलब्ध छ<br> 2. निश्चित गर्नुहोस् कि तपाइँ <a href=kotatsu://about>Kotatsu को नवीनतम संस्करण</a> प्रयोग गर्दै हुनुहुन्छ<br> 3. यदि यो उपलब्ध छ भने, विकासकर्ताहरूलाई त्रुटि रिपोर्ट पठाउनुहोस्। जानरा अलग गर्नु सुझावहरू अपडेट गर्दै + तपाईंले चयन गर्नुभएको फिल्टरसँग मिल्दो कुनै माङ्गा छैन + डाउनलोड ढिलो गरियो + तपाईंको IP ठेगाना अवरुद्ध हुनबाट जोगिन मद्दत गर्दछ + ग्रिड भ्यू + विधाहरू निर्दिष्ट गर्नुहोस् जुन तपाईं सुझावहरूमा हेर्न चाहनुहुन्न + यन्त्रबाट चयन गरिएका वस्तुहरू स्थायी मेट्ने हो? + मांगा प्रशोधन सेब गरियो + हटाउने कार्य सम्पन्न भयो + पुन: प्रयास गर्नुहोस् + पछाडि + अध्याय पृष्ठभूमिमा हटाइनेछ + रद्द गरियो + खाता पहिल्यै अवस्थित छ + सिंक्रोनाइजेसन + आफ्नो डाटा सिंक गर्नुहोस् + जारी राख्न आफ्नो इमेल प्रविष्ट गर्नुहोस् + लुकाउनुहोस् + पृष्ठहरू सुरक्षित गरियो \ No newline at end of file diff --git a/app/src/main/res/values-night/colors.xml b/app/src/main/res/values-night/colors.xml index 3bb43556e..8b306ab21 100644 --- a/app/src/main/res/values-night/colors.xml +++ b/app/src/main/res/values-night/colors.xml @@ -9,6 +9,7 @@ #222222 #81C784 #E57373 + #C8000000 #191C1C diff --git a/app/src/main/res/values-nn/strings.xml b/app/src/main/res/values-nn/strings.xml index 7c82111c8..f88447730 100644 --- a/app/src/main/res/values-nn/strings.xml +++ b/app/src/main/res/values-nn/strings.xml @@ -37,7 +37,7 @@ Nettserie Rutenettstorleik Slett mangaen - Slett «%s» ifrå eininga\? + Slett «%s» ifrå eininga? Lesing Bla med Hald fram diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index a3045a0fb..54bbdac4a 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -18,4 +18,4 @@ ਨੈੱਟਵਰਕ ਖਰਾਬ ਲਿਸਟ ਵੇਰਵਾ ਲਿਸਟ - + \ No newline at end of file diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 8e825269c..680120579 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -697,7 +697,7 @@ Erro de plugin: %s\n· Certifique-se de que você está usando a versão mais recente do plugin e Kotatsu Descendente Baixar novos capítulos - Retentar + Tentar novamente Conexão OK Muitas solicitações. Tente novamente depois de %s Recurso instável @@ -749,4 +749,22 @@ Sempre pergunte Baixando pelos dados móveis Permitir baixar pelos dados móveis? + Redefinição de conexão pelo host remoto + Mostrar controle deslizante + Verifique se a API funciona + O ID do bate-papo não está definido + ID do bate-papo do telegram + Abra o bot do Telegram + Limpar banco de dados + Excluir informações sobre mangás que não são utilizadas + Teste de conexão + Mensagem de teste + Tradução + Enviar backups no Telegram + Pressione para abrir o chat com o Kotatsu Backup Bot + Incógnito + Insira o ID do chat para onde os backups devem ser enviados + Autor + Avaliação + Fonte \ No newline at end of file diff --git a/app/src/main/res/values-pt/strings.xml b/app/src/main/res/values-pt/strings.xml index 403e70fa0..8727143d6 100644 --- a/app/src/main/res/values-pt/strings.xml +++ b/app/src/main/res/values-pt/strings.xml @@ -40,7 +40,7 @@ Remover «%s» deletado do armazenamento local Salvar página - Salvo + Página salva Compartilhar imagem Importar Atualizado @@ -729,4 +729,55 @@ Permitir sempre Permitir uma vez Perguntar sempre - \ No newline at end of file + Mostrar slider + Manhua + Manhwa + Erro de plug-in: %s\nCertifique-se de estar usando a versão mais recente do plugin e do Kotatsu + Orientação da tela + Conexão redefinida pelo host remoto + Incógnita + Um tiro + Criança + Verifique se a API funciona + Mensagem de teste + O ID do bate-papo não está definido + ID do bate-papo do Telegram + Abra o bot do Telegram + Páginas salvas + Shounen + Shoujo + Jovem + Mulher + Retrato + Paisagem + Acesso negado (403) + Fanzine + Esta fonte requer a resolução de um captcha para continuar + Lidar com links + Lidar com links de mangá de aplicativos externos (por exemplo, navegador da web). Você também pode precisar habilitá-lo manualmente nas configurações do sistema do aplicativo + Email + Formato inválido: imagem esperada, mas obteve %s + Número máximo de backups + Envie backups no Telegram + Conexão de teste + Insira o ID do chat para onde os backups devem ser enviados + Pressione para abrir o bate-papo com o Kotatsu Backup Bot + Exclua backups antigos + Exclua automaticamente arquivos de backup antigos para economizar espaço de armazenamento + Tradução + Autor + Avaliação + Fonte + Limpar banco de dados + Excluir informações sobre mangás que não são utilizadas + Ativar todas as fontes de manga + Todas as fontes de manga seram ativadas permanentemente + Todas as fontes estão ativas + Barra de informações do leitor transparente + O backup será restaurado em segundo plano + Restaurando backup + Controles do leitor na barra inferior + Capítulos e páginas + A rotação da tela foi bloqueada + A rotação da tela foi desbloqueada + diff --git a/app/src/main/res/values-ru/strings.xml b/app/src/main/res/values-ru/strings.xml index f5b55b409..83a7c423f 100644 --- a/app/src/main/res/values-ru/strings.xml +++ b/app/src/main/res/values-ru/strings.xml @@ -749,4 +749,51 @@ Страницы сохранены Удалить старые резервные копии Автоматически удалять старые резервные копии чтобы освободить место для данных - \ No newline at end of file + Сброс соединения удалённым хостом + Показать слайдер + Инкогнито + Проверьте, работает ли API + Тестовое сообщение + ID чата не установлен + ID чата Telegram + Откройте Telegram-бота + Этот источник требует решения капчи, чтобы продолжить + Электронная почта + Очистить базу данных + Удалить информацию о манге, которая не используется + Включить все источники манги + Тестовое соединение + Отправлять резервные копии в Telegram + Введите ID чата, куда следует отправлять резервные копии + Нажмите, чтобы открыть чат с Kotatsu Backup Bot + Обработка ссылок + Все доступные источники манги будут включены навсегда + Перевод + Все источники включены + Источник + Рейтинг + Автор + Обработка ссылок на мангу из внешних приложений (например, веб-браузера). Вам также может потребоваться включить его вручную в системных настройках приложения + Резервная копия будет восстановлена в фоновом режиме + Восстановление резервной копии + Прозрачная информационная панель чтения + Простой + Элементы управления чтением на нижней панели + Главы и страницы + Ползунок переключения страниц + Поворот экрана был заблокирован + Поворот экрана был разблокирован + Глобальный поиск + Поиск повсюду + Значки в списках + Подробности ошибки + Похоже, что Ваша версия Kotatsu устарела. Пожалуйста, установите последнюю версию, чтобы получить все доступные исправления. + Вы можете отправить отчёт об ошибке разработчикам. Это поможет нам исправить проблему. + Поиск по отключенным источникам + Том %1$s Глава %2$s + Глава %s + Безымянная глава + Попробуйте открыть мангу в браузере, чтобы убедиться, что она доступна в источнике. + Отключить уведомления о CAPTCHA + Вы не будете получать уведомления о прохождении CAPTCHA для этого источника, но это может привести к тому, что фоновые операции перестанут работать (проверка новых глав, обновление рекомендаций и т. д.) + diff --git a/app/src/main/res/values-sr/strings.xml b/app/src/main/res/values-sr/strings.xml index 01205c8a4..d61adb112 100644 --- a/app/src/main/res/values-sr/strings.xml +++ b/app/src/main/res/values-sr/strings.xml @@ -128,7 +128,7 @@ Избриши Очишћено \"%s\" избрисано из локалне меморије - Увоз + Увези Преузми Подели %s Домен @@ -152,7 +152,7 @@ Можеш да користиш послуживач за синхронизацију који се самостално хостује или подразумевани. Не мењај ово ако ниси сигуран шта радиш. Враћени су неважећи подаци или је датотека оштећена Све омиљене - Унесите своју адресу е-поште да бисте наставили + Унеси своју адресу е-поште за наставак Изабери прилагођени директоријум Нема поглавља Листа опција @@ -226,7 +226,7 @@ Предлози Изабери поглавља ручно Смањује траке, али може утицати на учинак - Увоз манге + Увези манге Омогућено Заустави Такође очисти информације о новим поглављима @@ -294,7 +294,7 @@ Притисни Назад поново да изађеш Режим скалирања Напредна - Само на ВиФи-ју + Само на ВиФи мрежи Подешавања синхронизације Назад Варијанта са мреже @@ -307,7 +307,7 @@ Други кеш DNS преко HTTPS-а Прикажи сумњив садржај - Синхронизујте своје податке + Синхронизуј своје податке Манга из ваших Омиљених Можеш да изабереш једну или више .cbz или .zip датотека, свака датотека ће бити препозната као засебна манга. Преузимања су заустављена @@ -371,7 +371,7 @@ Синхронизација Пронађи поглавље Потврда изласка - По прилагођеном избору + Прилагођеном избору Архива стрипова Више Асука @@ -455,7 +455,7 @@ Никада Сачувај или одбаци несачуване промене? Заустављано - Фолдер са сликама + Фасцикла са сликама Сви колачићи су уклоњени Искључи оптимизацију батерије Планирано @@ -592,7 +592,7 @@ Премештање је завршено Замене Премести - Манга „%1$s“ из „%2$s“ ће бити замењена са „%3$s“ из „%4$s“ у вашој Историји и Омиљеним (ако постоји) + Манга %1$s из %2$s ће бити замењена са %3$s из %4$s у вашој Историји и Омиљеним (ако постоји) Избриши поглавља која си већ прочитао из локалне меморије да би ослободио простор Ово ће трајно избрисати сва поглавља означена као прочитана из твог локалног складишта. Можеш их поново преузети касније, али увезена поглавља могу бити изгубљена заувек Давно прочитано @@ -610,7 +610,7 @@ Овај извор манге није подржан Прикажи сличице страница Никакви подаци нису примљени са послуживача - Омогући картицу „Странице“ на екрану са детаљима + Омогући картицу узраста на екрану са детаљима Изабери одговарајућу Kotatsu датотеку резервне копије %d ч %1$d ч %2$d м @@ -619,7 +619,7 @@ Поправи Прикажи ажурирано Прикажи усправне празнине између страница у Вебтун режиму - Последња кориштена + Последњој кориштеној Празнине у режиму Вебтун-а Предлози за претрагу Недавни упити @@ -689,10 +689,10 @@ Манга са преузетим поглављима Поправљање манге Успешно поправљено - Није потребна исправка за „%s“ + Није потребна исправка за s\\ Ова функција ће пронаћи алтернативне изворе за изабрану мангу. Задатак ће потрајати и наставиће се у позадини - Манга „%1$s“ (%2$s) је замењена са „%3$s“ (%4$s) - Нису пронађене алтернативе за „%s“ + Манга 1$s (%2$s) је замењена са 3$s (%4$s) + Нису пронађене алтернативе за s\\ Роман Манхуа Манхва @@ -749,8 +749,43 @@ Приступ одбијен (403) Избриши старе резервне копије Аутоматски избриши старе датотеке резервних копија да бис уштедео простор за складиштење - Руковање манга везама из спољних апликација (нпр. из прегледача). Можда ћеш морати да је омогућиш и ручно у системским поставкама апликације + Отварање манга веза из спољних апликација (нпр. из прегледача). Можда ћеш морати да ручно омогућиш у системским поставкама апликације Руковање везама Е-пошта Овај извор захтева решавање CAPTCHA за наставак - \ No newline at end of file + Аутор + Оцена + Извор + Превод + Инкогнито + Отвори Телеграм бот-а + Телеграм ИД за ћаскање + ИД ћаскања није подешен + Ресетовање везе од стране удаљеног домаћина + Провери да ли API ради + Пробна порука + Прикажи клизач + Унеси ИД ћаскања на који треба послати резервне копије + Пошаљи резервне копије у Телеграм + Провери везу + Додирни да отвориш ћаскање са Котатсу Ботом за резервну копију + Избриши базу података + Избриши информације о Манги које се не користе + Сви доступни Манга извори биће трајно омогућени + Омогући све Манга изворе + Сви извори су укључени + Резервна копија ће бити враћена у позадини + Провидна информативна трака читача + Управљач читача у доњој траци + Клизач за пребацивање страница + Окретање екрана је закључано + Обртање екрана је откључано + Враћање резервне копије + Поглавља и странице + Једноставно + Глобална претрага + Тражи свуда + Значке у листама + Онемогући Цаптцха обавештења + Нећете добијати обавештења о решавању ЦАПТЦХА за овај извор, али то може довести до прекида позадинских операција (проверавање нових поглавља, добијање препорука итд.) + diff --git a/app/src/main/res/values-sv/plurals.xml b/app/src/main/res/values-sv/plurals.xml index 4769abc96..d89e19905 100644 --- a/app/src/main/res/values-sv/plurals.xml +++ b/app/src/main/res/values-sv/plurals.xml @@ -1,27 +1,39 @@ - - %1$d artikel - %1$d artiklar - - - %1$d nytt kapitel - %1$d nya kapitel - - - %1$d kapitel - %1$d kapitel - - - %1$d minut sedan - %1$d minuter sedan - - - %1$d timme sedan - %1$d timmar sedan - - - %1$d dag sedan - %1$d dagar sedan - + + %1$d artikel + %1$d artiklar + + + %1$d nytt kapitel + %1$d nya kapitel + + + %1$d kapitel + %1$d kapitel + + + %1$d minut sedan + %1$d minuter sedan + + + %1$d timme sedan + %1$d timmar sedan + + + %1$d dag sedan + %1$d dagar sedan + + + %1$d månad sedan + %1$d månader sedan + + + %1$d timme + %1$d timmar + + + %1$d minut + %1$d minuter + diff --git a/app/src/main/res/values-sv/strings.xml b/app/src/main/res/values-sv/strings.xml index 7d03f4b8b..38ae4e6a7 100644 --- a/app/src/main/res/values-sv/strings.xml +++ b/app/src/main/res/values-sv/strings.xml @@ -7,7 +7,7 @@ Favoriter Historik Ett fel har inträffat - Lyckades inte ansluta till internet + Nätverksfel Detaljer Kapitel Lista @@ -15,7 +15,7 @@ Rutnät Listläge Inställningar - Källor + Mangakällor Laddar… Beräknar… Kapitel %1$d av %2$d @@ -57,7 +57,7 @@ Rensa \"%s\" borttaget från lokal lagring Spara sida - Sparad + Sida sparad Dela bild Importera Ta bort @@ -129,7 +129,7 @@ Förslag Aktivera förslag Föreslå manga baserat på dina preferenser - Alla data analyseras lokalt på denna enhet. Dina personuppgifter kommer inte överföras till någon tjänst + Alla data analyseras lokalt på denna enhet och skickas inte någon annanstans. Börja läsa manga för att få personliga förslag Föreslå inte manga med vuxeninnehåll Aktiverad @@ -159,15 +159,15 @@ Hylla Ta bort Ny kategori - Hitta ny läsning i sidomenyn. + Hitta något att läsa i sektionen «Utforska» Sidanimation Tom kategori Läs senare Uppdateringar Ny version: %s - Hämta från onlinekällor eller importera filer. + Spara något från en onlinekatalog eller importera från en fil. Nytt - Mapp för nedladdningar + Nedladdningsmapp Ej tillgänglig Annan lagring Nya kapitel av det du läser visas här @@ -191,7 +191,7 @@ Om Version %s Leta efter uppdateringar - Höger-till-vänster (←) + Höger-till-vänster Skalningsläge Centrera Anpassa mot höjd @@ -230,7 +230,7 @@ Hjälper till med att leta efter uppdateringar i bakgrunden Nya mangakällor finns tillgängliga Anmäl - Kapitel kommer att tas bort i bakgrunden. Det kan ta lite tid + Kapitel kommer att tas bort i bakgrunden Spårning Logga ut Standardläge @@ -240,12 +240,12 @@ Läst Pausad Övergiven - Använd fingeravtryck om tillgängligt + Använd biometrik om tillgängligt Manga från dina favoriter Visa indikatorer om läsförlopp Radering av data Visa hur långt du har läst i procent på listor för historik och favoriter - Manga markerad som NSFW kommer aldrig läggas till i historiken och din framfart kommer inte att sparas + Manga markerad för vuxna kommer aldrig läggas till i historiken och ditt läsförlopp kommer inte att sparas Kan vara till hjälp för att lösa vissa problem. Alla auktoriseringar kommer att ogiltigförklaras Begränsa nedladdningshastighet Leta efter och avisera om nya kapitel @@ -269,4 +269,469 @@ Innehållet kunde inte hittas eller har tagits bort Något gick fel. Skicka en felrapport till utvecklarna för att hjälpa oss att åtgärda problemet. Skicka - \ No newline at end of file + Skriv in din email för att fortsätta + Rensa all historik + Inga bokmärken ännu + Bokmärken borttagna + Du kan skapa bokmärken medan du läser manga + Tryck Tillbaka igen för att avsluta + Avslutningsbekräftelse + Visa informationsfältet i läsaren + Färgkorrigering + Kasta + Inget mer lagringsutrymme finns kvar på enheten + Slå på Wi-Fi eller mobildata för att läsa manga online + Dela loggar + Aktivera loggning + Sidor sparade + Inga manga matchade dina valda filter + Avbruten + Tillbaka + Synkronisering + Kontot finns redan + Senaste 2 timmarna + Hantera + Är du säker på att du vill radera de valda favoritkategorierna?\nAll manga i dem går förlorade och du kan inte ångra åtgärden. + Ändra ordning + Tryck Tillbaka två gånger för att avsluta appen + Visa genvägar för nyligen läst manga + Ingen nätverksanslutning tillgänglig + Källa inaktiverad + Markera som aktuell + Språk + Spela in några handlingar i felsökningssyfte. Slå inte på om du är osäker på vad du gör + Rutnätsvy + Synkronisera din data + Inga mangakällor + Tom + Historiken rensad + Inga kapitel + Inkognitoläge + Automatisk rullning + Kap. %1$d/%2$d Sida %3$d/%4$d + Återställ + Spara eller kasta osparade ändringar? + Kompakt + Slumpmässigt + Slå på mangakällor för att läsa manga online + Alternativ + Borttagen från favoriter + Utforska + Seriearkiv + Mapp med bilder + Mangaimport + Import slutförd + Du kan radera originalfilen från lagringen för att spara utrymme + Importeringen börjar snart + Felinformation:<br><tt>%1$s</tt><br><br>1. Försök att <a href=%2$s>öppna manga i en webbläsare</a> för att säkerställa att den finns tillgänglig hos källan<br>2. Kolla att du använder <a href=kotatsu://about>den senaste versionen av Kotatsu<br>3. Om den finns tillgänglig, skicka en felrapport till utvecklarna. + Felaktig serveradress + Sparade manga + Sidocache + Annan cache + Flöde + Lagringsanvändning + Tillgänglig + Kontrast + Servern gav felet %1$d. Var god försök igen senare + Visa nyligen läst manga när du trycker länge på app-ikonen + Ljusstyrka + Rensa även information om nya kapitel + Försök igen + Kommande + Hentai + Serier + Ta emot aviseringar om instabila uppdateringar + Jag förstår + Pausa + Återställ + Exkludera genrer + Innehållsklassificering + Justera inte hållet sidorna vänds åt i läsläget, t.ex. trycker du på höger piltangent bläddrar du alltid framåt. Denna inställning påverkar endast inmatningsenheter + Dynamisk + Visa i rutnätsvy + Det finns inget här + Tjänster + Tillåt instabila uppdateringar + Du kan välja en eller flera .cbz- eller .zip-filer. Varje fil kommer läsas in som separata manga. + Innehållsnedladdning på förhand + För att spåra ditt läsförlopp, tryck på Meny → Spårning på mangans informationsskärm. + Ignorera SSL-fel + Välj spegelserver automatiskt + Återuppta + Pausad + Ta bort färdiglästa + Ladda endast ned via Wi-Fi + Visa ibland aviseringar med föreslagna manga + Mer + Du kan välja en mapp med arkiv eller bilder. Varje arkiv (eller undermapp) kommer läsas in som ett kapitel. + Aktivera + Nej tack + Alla aktiva nedladdningar kommer avbrytas och delvis nedladdat data går förlorat + Du har inga nedladdningar + Nedladdningarna har återupptagits + Nedladdningarna har pausats + Adress + Nedladdad + Användarnamn + Ogiltigt portnummer + Nätverk + %s första + Kommande %s olästa + Ljus + Skriv in en mangatitel, genre eller namn på källa + Okänd + Ogiltigt data har returnerats eller så är filen korrupt + Huruvida zoomknapparna ska visas i det nedre högra hörnet + Minskar på färgartefakter men kan på påverka prestanda + 32-bitars färgläge + Varje dag + Senast lyckad säkerhetskopia: %s + x%.1f + Manga + Katalog + Manuell + Tillgängliga: %1$d + Inaktivera vuxenkällor och visa om möjligt inte manga för vuxna i listan + Pausad + Minska minnesanvändning (beta) + Minska kvalitén på sidor som inte visas för att använda mindre minne + Filtrering efter flera genrer stöds inte av denna mangakälla + Du kan aktivera långsam nedladdning för varje mangakälla individuellt i källans inställningar om du har problem med att servern blockerar dig + Hoppa över + Globalt + Gråskala + Denna manga + Zoomning i webtoons + Visa sidväxlarreglaget + Du kan logga in i ett existerande konto eller skapa ett nytt + Hastighet + Visa på Bokhyllan + Hitta liknande + Synkroniseringsinställningar + Byt automatiskt mangakällors domän om spegelservrar finns tillgängliga + Avbryt alla + WebView är inte tillgängligt; kontrollera om en WebView-leverantör är installerad + Rensa nätverkscachen + Ogiltigt värde + Invertera färger + Återställ från en tidigare säkerhetskopia + Data och integritet + Uppdatera inte förslag vid användning av anslutningar med datapriser + Kolla inte efter nya kapitel vid användning av anslutningar med datapriser + Avancerat + Lås skärmens rotation + Tillagd + Det finns inga källor tillgängliga i denna sektion eller så har alla redan lagts till.\nHåll utkik + Källa aktiverad + Hantera källor + Nedladdning påbörjades + Mangalista + Skriv in din email och lösenord för att fortsätta + Använd tjänsten wsrv.nl för att minska på nätverkstrafiken och påskynda bildnedladdning om möjligt + Visa sidnummer i skärmens nedre hörn + Alla kapitel med översättning %s + Röstsökning + Relaterad manga + Svart + Visa en lista över relaterade manga. I vissa fall kan den vara felaktig eller saknas + Inaktivera vuxeninnehåll + På enheten + Mappar + Zooma in + Flyttad till toppen + Zooma ut + Håll skärm påslagen + Stäng inte av skärmen medan du läser manga + Listalternativ + Regelbunden säkerhetskopiering + Varannan dag + En gång i veckan + Två gånger i månaden + Säkerhetskopiornas utmatningsmapp + Sökfunktionen stöds inte av denna mangakälla + Namn omvänd + Visa + Proxy för bildoptimisering + För många förfrågningar. Försök igen senare + Typ + Proxy + Visa misstänksamt innehåll + Färgtema + Miku + Serveradress + Du kan använda din egna synkroniseringsserver eller den som är standard. Ändra inte detta om du inte är säker på vad du gör. + Ergonomiska kontroller i läsläge + Tryck och håll ned på ett objekt för att ändra deras ordning + Var god starta om appen för att verkställa ändringarna + Rensa kakorna för en specifik domän. I flesta fall ogiltiggörs tidigare auktorisering + Hela mangan + Tillåt inzoomningsgest i webtoon-läge + Alla olästa kapitel (%s) + Välj en egen mapp + Alla olästa kapitel + Lokala mangamappar + Denna månad + Beskrivning + Bakgrund + Data återställdes inte + Mörk + Hantera kategorier + Vit + Språk + %s kräver att en captcha löses för att fungera korrekt + Till toppen + Inga fler objekt kan läggas till + Visa zoomknappar + Kategorier + Annat + Källkatalog + Inga tillgängliga mangakällor hittades med din sökning + Filtrering efter både genre och språk stöds inte av denna källa + Börja skriva in namnet på genren + De här inställningarna kan verkställas globalt eller endast för den aktuella mangan. Om du väljer globalt kommer individuella inställningar inte att överskuggas. + Verkställ + Var god välj vilka innehållskällor du vill aktivera. Detta kan även konfigureras i inställningarna vid ett senare tillfälle + Kan hjälpa till att få nedladdningen att sätta igång om du har problem med det + Vill du få personliga mangarekommendationer? + Förslag: %s + Aktivera regelbunden säkerhetskopiering + Nedladdningarna har tagits bort + Nedladdningarna har avbrutits + För många förfrågningar. Försök igen efter %s + Du har inte tillgång till denna fil eller mapp + Välj kapitel manuellt + Visa aktuell tid och läsförlopp längst upp på skärmen + Lösenord + Auktorisering (valfritt) + Relevans + Kontrollera att du har valt den rätta säkerhetskopian + Föreslå nya källor efter appen uppdaterats + En gång i månaden + Din nedladdningshistorik kommer tas bort permanent. Inga nedladdade filer påverkas + Stoppa nedladdningar vid anslutning till ett mobilt nätverk + Port + Förlopp + Huvudskärmssektioner + Tillfråga om att aktivera nyligen tillagda källor efter en applikationsuppdatering + Logga in på synkroniseringskonto + Pågår + Publiceringsstatus + Volym %d + Standardflik + Markera som färdigläst + Markera den valda mangan som färidgläst?\n\nVarning: aktuellt läsförlopp kommer att gå förlorat. + Okänd volym + Ditt läsförlopp kommer ej att sparas + Visa meny + Visa/göm gränssnitt + Tidigare kapitel + Nästa kapitel + Tidigare sida + Nästa sida + Konfigurera handlingarna för de tryckbara skärmområdena + Använd volymknapparna för att bläddra + Filtrering efter flera publiceringsstatusar stöds inte av denna mangakälla + Filtrering efter både genrer och publiceringsstatusar stöds inte av denna källa + Var god välj en vederbörlig Kotatsu-säkerhetskopiering + Manhua + Roman + Manhwa + Anslutning återställd av fjärrvärden + Återställer säkerhetskopia + Inkognito + Kontrollera om API:n fungerar + Testmeddelande + Chatt-ID är inte inställt + Telegram chatt-ID + Öppna Telegram-botten + %d t + Den beräknade lästiden kan vara inexakt + Sparar sidor + Föredraget nedladdningsformat + Förslagsfunktionen är inaktiverad + Statistik + Statistik rensad + Är du säker på att du vill rensa all läsarstatistik? Det går inte att ångra denna åtgärd. + Det finns ingen statistik för den valda perioden + Migrera + Migrering slutförd + Raderade %1$s, rensade %2$s + Rader kapitel du redan har läst från lokal lagring för att spara utrymme + Dela upp efter översättningar + Aktivera källa + Denna mangakälla stöds ej + Visa sidors tumnaglar + Aktivera \"Sidor\"-fliken på informationsskärmen + Ingen data tags emot från servern + %d m + %d s + Mindre frekvent + Mer frekvent + Intervall mellan kontroller + Sökförslag + Senaste sökningar + Alla språk + Källa fastnålad + Procent läst + Inkompatibelt tillägg eller internt fel. Kontrollera att du använder den senaste versionen av tillägget och Kotatsu + Anslutningen är OK + Ogiltig proxykonfiguration + Fallande + Logga in på %s för att fortsätta + Nyligen tillagda + Tillagda för länge sedan + Populära denna timme + Populära idag + Populära denna vecka + Denna källa stöder inte sök med filter. Dina filter har blivit borttagna + Stående + Bildsamling + Visa etiketter i navigeringsfältet + Denna källa kräver att du löser en captcha för att fortsätta + Transparent informationsfält i läsaren + Denna kategori har gömts från huvudskärmen och är tillgänglig via Meny → Hantera kategorier + Efterfråga destinationsmappen varje gång + Rensa statistik + Radera färdiglästa kapitel + Antal lästa sidor: %s + Manga \"%1$s\" från \"%2$s\" kommer att bli ersatt med \"%3$s\" från \"%4$s\" i din historik och favoriter (om närvarande) + Aktivera alla mangakällor + Alla tillgängliga mangakällor kommer aktiveras permanent + Alla källor har aktiverats + Beskär sidor + Använd dubbelsidig layout vid liggande skärmorientering (beta) + Hantera länkar + Hantera mangalänkar från externa applikationer (t.ex. webbläsare). Du kan även behöva tillåta det manuellt i systemets inställningar för applikationen + E-mail + Källor lösgjorda + Tilläggsfel: %s\nKontrollera att du använder den senaste versionen av tillägget och Kotatsu + Visa snabbfilter + Hoppa över alla + Uppdaterad för länge sedan + Opopulär + Dåligt betyg + Stigande + Låter dig filtrera mangalistor efter särskilda parametrar + Popularitet + Instabil funktion + Denna funktion är experimentell. Var god säkerställ att du har en säkerhetskopia för att undvika dataförlust + Datum + Populära denna månad + Originalspråk + Demografi + Användarhandbok + Källkod + Telegram-grupp + Genre + Nedladdning tillagd + Fler val + Nedladdning sker över mobilnätet + Tillåta nedladdningar över mobilnätet? + Alla + Tillåt inte + Tillåt alltid + Liggande + Läsarstatistik + Spara den valda mangan? Detta kan komma att använda nätverket och ta upp lagringsutrymme + Spara manga + Tillåt en gång + Fråga varje gång + Åtkomst nekad (403) + Max antal säkerhetskopior + Ingen reparation krävdes för \"%s\" + Inga alternativ funnits för \"%s\" + Rensa databas + Radera information om manga som inte används + Skicka säkerhetskopior via Telegram + Ange chatt-ID:t som säkerhetskopior ska skickas till + Radera gamla säkerhetskopior + Radera gamla säkerhetskopior automatiskt för att spara lagringsutrymme + Bildformat saknar stöd: %s + Översättning + Mangamigrering + Radera automatiskt färdiglästa kapitel + Visa vertikala springor mellan sidorna i webtoon-läge + %1$d t %2$d m + Fastnåla navigeringsgränssnittet + Göm inte navigeringsfältet och sökvyn vid rullning + Föreslagna sökningar + Författare + Inaktivera + Källor inaktiverade + Hoppar över anslutningskontrollen ifall du har problem med den (t.ex. offline-läge aktiveras trots att du har en internetanslutning) + Inaktivera anslutningskontroll + Fastnåla + Blockera i inkognitoläge + Föredragen bildserver + Procent kvar + Källa lösgjord + Läsarkontroller i nedre fältet + Kapitel och sidor + Sidväxlarreglage + Skärmrotationen har låsts upp + Skärmrotationen har låsts fast + Helskärmsläge + Göm statusfältet och navigeringsfältet + Annan manga + Standardmapp för sparade sidor + Ta bort från historiken + Mindre än en minut + Kapitel kvar + Detta kommer permanent radera alla kapitel märkta som färdiglästa från lokal lagring. Du kan ladda ned dom på nytt senare men de importerade kapitlena kan gå förlorade för evigt + Du kan inaktivera verifiering av SSL-certifikat om du stöter på SSL-relaterade problem när du försöker nå nätverksresurser. Detta kan påverka anslutningens säkerhet. Omstart av applikationen krävs för att ändringen ska verkställas. + Kapitel lästa + Källor fastnålade + Ladda ned nya kapitel + Säkerhetskopian kommer att återställas i bakgrunden + Ogiltigt format: förväntade en bild men fick %s + Bakgrundsnedladdningar + Logga in för att ställa in integreringen med %s. Detta tillåter dig att spåra ditt mangaläsförlopp och status + Manga med nedladdade kapitel + Populära detta år + Du kan välja kapitlena du vill ladda ned genom att långtrycka på ett objekt i kapitellistan. + År + Denna funktion letar efter alternativa källor för den valda mangan. Processen kommer ta lite tid och fortgår i bakgrunden + Destinationsmapp + Skärmorientering + Manga \"%1$s\" (%2$s) har ersatts av \"%3$s\" (%4$s) + Reparera manga + Reparation lyckades + Extern/tillägg + Visa beräknad lästid + Påbörja nedladdning + Körs när applikationen startar + Senast använd + Springor i webtoon-läge + Visa kapitel med olika översättningar separat istället för i en sammanslagen lista + Äldsta + %1$d m %2$d s + Visa uppdaterade + Du är blockerad av servern. Försök med en annan nätverksanslutning (VPN, proxy, osv.) + Felsökningsinformation om bakgrundskontroller av nya kapitel + Visa inte aviseringar gällande uppdateringar av manga med vuxeninnehåll + Inaktivera aviseringar för vuxeninnehåll + Ej i favoriterna + Appen har inte tillåtelse att få tillgång till manga på extern lagring + Reparera + Inga kapitel har raderats + Sök globalt + Författare + Betyg + Källa + Sök överallt + Märken i listor + Kanade + Alla åldrar + Suggestiv + Asuka + Mion + Rikka + Sakura + Mamimi + Säkerhetskopieringsintervall + Säkerhetskopieringsdatum: %s + Senast läst + Läsarhandlingar + Aktivera volymknapparna + Övergiven + diff --git a/app/src/main/res/values-ta/plurals.xml b/app/src/main/res/values-ta/plurals.xml new file mode 100644 index 000000000..280281dc6 --- /dev/null +++ b/app/src/main/res/values-ta/plurals.xml @@ -0,0 +1,39 @@ + + + + %1$d மணித்துளி + %1$d நிமிடங்கள் + + + %1$d உருப்படி + %1$d உருப்படிகள் + + + %1$d புதிய அத்தியாயம் + %1$d புதிய அத்தியாயங்கள் + + + %1$d அத்தியாயம் + %1$d அத்தியாயங்கள் + + + %1$d மணித்துளி முன்பு + %1$d நிமிடங்களுக்கு முன்பு + + + %1$d மணி நேரத்திற்கு முன்பு + %1$d மணி நேரத்திற்கு முன்பு + + + %1$d நாள் முன்பு + %1$d சில நாட்களுக்கு முன்பு + + + %1$d மாதத்திற்கு முன்பு + %1$d மாதங்களுக்கு முன்பு + + + %1$d மணி + %1$d மணிநேரம் + + \ No newline at end of file diff --git a/app/src/main/res/values-ta/strings.xml b/app/src/main/res/values-ta/strings.xml new file mode 100644 index 000000000..0eb4102b9 --- /dev/null +++ b/app/src/main/res/values-ta/strings.xml @@ -0,0 +1,768 @@ + + + \"%s\" உள்ளக சேமிப்பகத்திலிருந்து நீக்கப்பட்டது + பக்கத்தை சேமிக்கவும் + பக்கம் சேமிக்கப்பட்டது + பக்கங்கள் சேமிக்கப்பட்டன + படத்தைப் பகிரவும் + இறக்குமதி + நீக்கு + இந்த செயல்பாடு ஆதரிக்கப்படவில்லை + ஒரு சிப் அல்லது சிபிஇசட் கோப்பைத் தேர்ந்தெடுக்கவும். + விளக்கம் இல்லை + பக்க கேச் அழிக்கவும் + b | kb | Mb | g | tb + கட்டம் அளவு + தரநிலை + வெப்டூன் + பயன்முறையைப் படியுங்கள் + %s தேடுங்கள் + கிளைகளை நீக்கு + சாதனத்திலிருந்து \"%s\" ஐ நிரந்தரமாக நீக்கவா? + தொடரவும் + வாசகர் அமைப்புகள் + பக்கங்களை மாற்றவும் + பிழை + சிறுபடங்களை அழிக்கவும் + தேடல் வரலாற்றை அழிக்கவும் + அழிக்கப்பட்டது + உள் சேமிப்பு + வெளிப்புற சேமிப்பு + டொமைன் + பயன்பாட்டின் புதிய பதிப்பு கிடைக்கிறது + உள்ளக சேமிப்பு + பிடித்தவை + வரலாறு + வலைவாய் + பட்டியல் பயன்முறை + அமைப்புகள் + மங்கா ஆதாரங்கள் + ஏற்றுகிறது… + கம்ப்யூட்டிங்… + அத்தியாயம் %1$d %2$d + மூடு + மீண்டும் முயற்சிக்கவும் + மீண்டும் முயற்சிக்கவும் + வரலாற்றை அழிக்கவும் + எதுவும் கிடைக்கவில்லை + பங்கு %s + இன்னும் வரலாறு இல்லை + படிக்க + இன்னும் பிடித்தவை இல்லை + இது பிடித்தது + புதிய வகை + கூட்டு + சேமி + பங்கு + குறுக்குவழியை உருவாக்கவும்… + தேடல் + மங்காவைத் தேடுங்கள் + பதிவிறக்கம்… + செயலாக்கம்… + பதிவிறக்கம் + பதிவிறக்கங்கள் + பெயர் + மக்கள் + புதுப்பிக்கப்பட்டது + புதியது + செயல்வரம்பு + வரிசைப்படுத்துதல் ஒழுங்கு + வடிப்பி + கருப்பொருள் + ஒளி + இருண்ட + அமைப்பைப் பின்தொடரவும் + பக்கங்கள் + தெளிவான + அகற்று + அறிவிப்புகள் + வலை உலாவியில் திறக்கவும் + %1$d %2$d + புதிய அத்தியாயங்கள் + பதிவிறக்கம் + அறிவிப்புகள் அமைப்புகள் + அறிவிப்பு ஒலி + எல்.ஈ.டி காட்டி + அதிர்வு + பிடித்த பிரிவுகள் + அகற்று + இது இங்கே காலியாக உள்ளது… + வினவலை மறுசீரமைக்க முயற்சிக்கவும். + காப்புப்பிரதி சேமிக்கப்பட்டது + அத்தியாயம் இல்லை + இந்த பயன்பாட்டை மொழிபெயர்க்கவும் + அங்கீகரிக்கப்பட்ட + %s இல் உள்நுழைவது ஆதரிக்கப்படவில்லை + மொழிபெயர்ப்பு + நீங்கள் அனைத்து மூலங்களிலிருந்தும் வெளியேறுவீர்கள் + வகைகள் + முடிந்தது + நடந்து கொண்டிருக்கிறது + இயல்புநிலை + திரைக்காட்சி கொள்கை + NSFW இல் தொகுதி + இசைவு + எப்போதும் தடுக்கும் + பரிந்துரைகள் + பரிந்துரைகளை இயக்கவும் + உங்கள் விருப்பங்களின் அடிப்படையில் மங்காவை பரிந்துரைக்கவும் + தவறான பதிலாள் உள்ளமைவு + முடிக்கப்பட்டபடி குறி + உள்ளடக்க மதிப்பீடு + வகைகளை விலக்கு + பாதுகாப்பானது + பரிந்துரைக்கும் + அகவை வந்தோர் + தேர்ந்தெடுக்கப்பட்ட மங்காவை முழுமையாகப் படித்தபடி மார்க்?\n\n எச்சரிக்கை: தற்போதைய வாசிப்பு முன்னேற்றம் இழக்கப்படும். + ஆசிரியர்கள் + சில அளவுருக்களால் மங்கா பட்டியல்களை வடிகட்டும் திறனை வழங்குகிறது + எச்.எஃப்.டபிள்யூ + விரைவான வடிப்பான்களைக் காட்டு + அனைத்தையும் தவிர்க்கவும் + சிக்கிக்கொண்டது + பிடித்தவைகளில் இல்லை + பட தொகுப்பு + கலைஞர் சி.சி. + விளையாட்டு சி.சி. + பிழைத்திருத்தம் + மூலக் குறியீடு + பயனர் கையேடு + தந்தி குழு + அனைத்தும் + ஆதரிக்கப்படாத பட வடிவம்: %s + தவறான வடிவம்: எதிர்பார்க்கப்படும் படம் ஆனால் %s கிடைத்தன + பதிவிறக்கத் தொடங்குங்கள் + தேர்ந்தெடுக்கப்பட்ட மங்காவைச் சேமிக்கவா? இது போக்குவரத்து மற்றும் வட்டு இடத்தை உட்கொள்ளலாம் + மங்காவைக் காப்பாற்றுங்கள் + வகை + பதிவிறக்கம் சேர்க்கப்பட்டது + மேலும் விருப்பங்கள் + இலக்கு அடைவு + அத்தியாய பட்டியலில் உள்ள உருப்படியை நீண்ட சொடுக்கு செய்வதன் மூலம் பதிவிறக்கம் செய்ய அத்தியாயங்களைத் தேர்ந்தெடுக்கலாம். + செல்லுலார் நெட்வொர்க்கில் பதிவிறக்குகிறது + வண்ண திருத்தம் + ஒளி + மீட்டமை + பிணையம் கிடைக்கவில்லை + கச்சிதமான + மூல முடக்கப்பட்டது + சந்தேகத்திற்கிடமான உள்ளடக்கத்தைக் காட்டு + மாறும் + மிகு + அசுகா + சகுரா + புன்னகை + கனடா + சேவைகள் + USERAGENT தலைப்பு + இந்த மாற்றங்களைப் பயன்படுத்த விண்ணப்பத்தை மறுதொடக்கம் செய்யுங்கள் + அலமாரியில் காட்டு + நீங்கள் ஏற்கனவே இருக்கும் கணக்கில் உள்நுழையலாம் அல்லது புதிய ஒன்றை உருவாக்கலாம் + ஒத்ததைக் கண்டறியவும் + பிழை ஏற்பட்டது + பிணைய பிழை + விவரங்கள் + பாடங்கள் + பட்டியல் + விரிவான பட்டியல் + முதலில் எதையாவது சேமிக்கவும் + அண்மைக் கால + பிற சேமிப்பு + தரவு காப்புப்பிரதியை உருவாக்கவும் + மீட்டெடுக்கப்பட்டது + எல்லா தரவுகளும் மீட்டெடுக்கப்பட்டன + குழு + இன்று + மீண்டும் முயற்சிக்க தட்டவும் + அமைதியாக + கேப்ட்சா தேவை + கடவுச்சொல் 4 எழுத்துக்கள் அல்லது அதற்கு மேற்பட்டதாக இருக்க வேண்டும் + வரவேற்கிறோம் + சில சாதனங்கள் வெவ்வேறு கணினி நடத்தைகளைக் கொண்டுள்ளன, அவை பின்னணி பணிகளை உடைக்கக்கூடும். + மேலும் வாசிக்க + வரிசையில் + என்.எச்.எஃப்.டபிள்யூ மங்காவை வரலாற்றிலிருந்து விலக்குங்கள் + எண்ணுள்ள பக்கங்கள் + தோற்றம் + வகைகளை விலக்கு + பரிந்துரைகளில் நீங்கள் பார்க்க விரும்பாத வகைகளைக் குறிப்பிடவும் + பின் + புதிய அத்தியாயங்களை சரிபார்த்து, அதைப் பற்றி அறிவிக்கவும் + வகையைத் திருத்து + Https க்கு மேல் dns + திட்டமிடப்பட்டது + அறிக்கை + தரவு நீக்குதல் + அனைத்தையும் காட்டு + வரம்பைத் தேர்ந்தெடுக்கவும் + நிர்வகிக்கவும் + மறுவரிசை + காலி + ஆராயுங்கள் + வெளியேறும் உறுதிப்படுத்தல் + மங்காவைக் காப்பாற்றினார் + பக்கங்கள் கேச் + பிற கேச் + சேமிப்பக பயன்பாடு + தீவனம் + பயன்பாட்டு ஐகானில் நீண்ட நேரம் அழுத்துவதன் மூலம் அண்மைக் கால மங்காவை கிடைக்கச் செய்யுங்கள் + அவ்வப்போது காப்புப்பிரதிகள் + தினமும் + பூட்டு திரை சுழற்சி + ஆதாரங்கள் பட்டியல் + மூல இயக்கப்பட்டது + மாநிலம் + இந்த மங்கா மூலத்தால் தேடலை ஆதரிக்கவில்லை + வகை பெயரைத் தட்டச்சு செய்யத் தொடங்குங்கள் + காப்பு தேதி: %s + இயல்புநிலை தாவல் + புதிய அத்தியாயங்களுக்கான பின்னணி காசோலைகள் பற்றிய தகவல்களை பிழைத்திருத்தவும் + புதிய + விருப்பமான பட சேவையகம் + ஏறுதல் + இறங்கு + திகதி + புகழ் + தொடர %s இல் உள்நுழைக + நிலையற்ற நற்பொருத்தம் + இன்று பிரபலமானது + இந்த வாரம் பிரபலமானது + ஆண்டு + ஆண்டுகள் + ஏதேனும் + அணுகல் மறுக்கப்பட்டது (403) + மின்னஞ்சல் + நூலாசிரியர் + பநிஇ வேலை செய்கிறதா என்று சரிபார்க்கவும் + அனைத்து ஆதாரங்களும் இயக்கப்பட்டுள்ளன + வெப்வியூ கிடைக்கவில்லை: வெப்வியூ வழங்குநர் நிறுவப்பட்டிருக்கிறாரா என்று சரிபார்க்கவும் + பிணையம் கேச் அழிக்கவும் + பதிலாள் + தவறான மதிப்பு + தொடர உங்கள் மின்னஞ்சல் மற்றும் கடவுச்சொல்லை உள்ளிடவும் + ஏற்பு (விரும்பினால்) + நீங்கள் படித்தவை இங்கே காண்பிக்கப்படும் + «எக்ச்ப்ளோர்» பிரிவில் என்ன படிக்க வேண்டும் என்பதைக் கண்டறியவும் + நீங்கள் தேர்ந்தெடுத்த வடிப்பான்களுடன் பொருந்தக்கூடிய மங்கா எதுவும் இல்லை + நிகழ்நிலை பட்டியலிலிருந்து ஏதாவது சேமிக்கவும் அல்லது ஒரு கோப்பிலிருந்து இறக்குமதி செய்யவும். + அலமாரி + பக்க அனிமேசன் + கோப்புறை பதிவிறக்குகிறது + கிடைக்கவில்லை + கிடைக்கக்கூடிய சேமிப்பு இல்லை + முடிந்தது + அனைத்து பிடித்தவைகளும் + வெற்று வகை + பின்னர் படியுங்கள் + புதுப்பிப்புகள் + நீங்கள் படிக்கிறவற்றின் புதிய அத்தியாயங்கள் இங்கே காட்டப்பட்டுள்ளன + தேடல் முடிவுகள் + புதிய பதிப்பு: %s + அளவு: %s + புதுப்பிப்புகளை அழிக்கவும் + அழிக்கப்பட்டது + திரை சுழற்றுங்கள் + புதுப்பிப்பு + தீவன புதுப்பிப்பு விரைவில் தொடங்கும் + புதுப்பிப்புகளைப் பாருங்கள் + சரிபார்க்க வேண்டாம் + கடவுச்சொல்லை உள்ளிடவும் + தவறான கடவுச்சொல் + பயன்பாட்டைப் பாதுகாக்கவும் + கோட்டாட்சுவைத் தொடங்கும்போது கடவுச்சொல்லைக் கேளுங்கள் + கடவுச்சொல்லை மீண்டும் செய்யவும் + பொருந்தாத கடவுச்சொற்கள் + பற்றி + பதிப்பு %s + புதுப்பிப்புகளை சரிபார்க்கவும் + வரவிருக்கும் + புதுப்பிப்புகள் எதுவும் கிடைக்கவில்லை + வலதுபுறம் இடது + புதிய வகை + அளவிலான பயன்முறை + பொருத்தம் நடுவண் + உயரத்திற்கு ஏற்றது + அகலத்திற்கு ஏற்றது + தொடக்கத்தில் வைத்திருங்கள் + கருப்பு + AMOLED திரைகளில் குறைந்த சக்தியைப் பயன்படுத்துகிறது + காப்புப்பிரதி மற்றும் மீட்டமை + காப்புப்பிரதியிலிருந்து மீட்டமைக்கவும் + தயாரித்தல்… + கோப்பு கிடைக்கவில்லை + தரவு மீட்டெடுக்கப்பட்டது, ஆனால் பிழைகள் உள்ளன + உங்கள் வரலாறு மற்றும் பிடித்தவைகளின் காப்புப்பிரதியை உருவாக்கி அதை மீட்டெடுக்கலாம் + இப்போது + நேற்று + நீண்ட காலத்திற்கு முன்பு + தேர்ந்தெடுக்கப்பட்ட உள்ளமைவு இந்த மங்காவுக்கு நினைவில் இருக்கும் + தீர்க்க + குக்கீகளை அழிக்கவும் + அனைத்து குக்கீகள் அகற்றப்பட்டன + தெளிவான ஊட்டம் + அனைத்து புதுப்பிப்பு வரலாற்றையும் நிரந்தரமாக அழிக்கவா? + புதிய அத்தியாயங்களை சரிபார்க்கவும் + தலைகீழ் + கட்டம் பார்வை + விடுபதிகை + இந்த உள்ளடக்கத்தைக் காண உள்நுழைக + இயல்புநிலை: %s + அடுத்தது + பயன்பாட்டைத் தொடங்க கடவுச்சொல்லை உள்ளிடவும் + உறுதிப்படுத்தவும் + அனைத்து அண்மைக் கால தேடல் வினவல்களையும் நிரந்தரமாக அகற்றவா? + எல்லா தரவுகளும் இந்த சாதனத்தில் உள்நாட்டில் மட்டுமே பகுப்பாய்வு செய்யப்படுகின்றன, மேலும் எங்கும் அனுப்பப்படவில்லை. + மங்காவைப் படிக்கத் தொடங்குங்கள், நீங்கள் தனிப்பயனாக்கப்பட்ட பரிந்துரைகளைப் பெறுவீர்கள் + NSFW மங்காவை பரிந்துரைக்க வேண்டாம் + இயக்கப்பட்டது + முடக்கப்பட்டது + வடிகட்டியை மீட்டமை + நீங்கள் மங்காவைப் படிக்க விரும்பும் மொழிகளைத் தேர்ந்தெடுக்கவும். நீங்கள் அதை பின்னர் அமைப்புகளில் மாற்றலாம். + ஒருபோதும் + வைஃபை மட்டுமே + எப்போதும் + பக்கங்களை முன்னரே ஏற்றவும் + %s ஆக உள்நுழைந்துள்ளது + 18+ + பல்வேறு மொழிகள் + அத்தியாயத்தைக் கண்டறியவும் + இந்த மங்காவில் அத்தியாயங்கள் இல்லை + பரிந்துரைகள் புதுப்பித்தல் + சாதனத்திலிருந்து தேர்ந்தெடுக்கப்பட்ட உருப்படிகளை நிரந்தரமாக நீக்கவா? + அகற்றுதல் முடிந்தது + மந்தநிலை பதிவிறக்கவும் + உங்கள் ஐபி முகவரியைத் தடுப்பதைத் தவிர்க்க உதவுகிறது + மங்கா செயலாக்கத்தை சேமித்தது + அத்தியாயங்கள் பின்னணியில் அகற்றப்படும் + ரத்து செய்யப்பட்டது + கணக்கு ஏற்கனவே உள்ளது + ஒத்தியக்கம் + உங்கள் தரவை ஒத்திசைக்கவும் + தொடர உங்கள் மின்னஞ்சலை உள்ளிடவும் + மறை + புதிய மங்கா ஆதாரங்கள் கிடைக்கின்றன + நீங்கள் படிக்கும் மங்காவின் புதுப்பிப்புகள் பற்றிய அறிவிப்புகளைப் பெறுவீர்கள் + பெயர் தலைகீழானது + நீங்கள் அறிவிப்புகளைப் பெற மாட்டீர்கள், ஆனால் புதிய அத்தியாயங்கள் பட்டியல்களில் முன்னிலைப்படுத்தப்படும் + அறிவிப்புகளை இயக்கவும் + பெயர் + தொகு + கண்காணிப்பு + பிடித்த பிரிவுகள் இல்லை + விடுபதிகை + புக்மார்க்கைச் சேர்க்கவும் + புத்தகக்குறியை அகற்று + புக்மார்க்குகள் + புக்மார்க்கு நீக்கப்பட்டது + புக்மார்க்கு சேர்க்கப்பட்டது + செயல்தவிர் + வரலாற்றிலிருந்து அகற்றப்பட்டது + இயல்புநிலை பயன்முறை + தன்னியக்க வாசகர் பயன்முறை + மங்கா வெப்டூன் என்றால் தானாகவே கண்டறியவும் + பேட்டரி தேர்வுமுறை முடக்கு + பின்னணி புதுப்பிப்புகள் சோதனைகளுக்கு உதவுகிறது + ஏதோ தவறு நடந்தது. அதை சரிசெய்ய எங்களுக்கு உதவ ஒரு பிழை அறிக்கையை டெவலப்பர்களிடம் சமர்ப்பிக்கவும். + அனுப்பு + படித்தல் + மறு வாசிப்பு + முடிந்தது + நிறுத்தி + கைவிடப்பட்டது + அனைத்தையும் முடக்கு + கிடைத்தால் பயோமெட்ரிக் பயன்படுத்தவும் + உங்களுக்கு பிடித்தவர்களிடமிருந்து மங்கா + உங்கள் அண்மைக் காலத்தில் படித்த மங்கா + வாசிப்பு முன்னேற்ற குறிகாட்டிகளைக் காட்டு + வரலாறு மற்றும் பிடித்தவைகளில் படித்த சதவீதத்தைக் காட்டு + இறக்குமதி விரைவில் தொடங்கும் + படிக்காத அனைத்து அத்தியாயங்களும் + என்.எச்.எஃப்.டபிள்யூ எனக் குறிக்கப்பட்ட மங்கா ஒருபோதும் வரலாற்றில் சேர்க்கப்படாது, உங்கள் முன்னேற்றம் காப்பாற்றப்படாது + சில சிக்கல்கள் ஏற்பட்டால் உதவ முடியும். அனைத்து அங்கீகாரங்களும் செல்லாதவை + தவறான டொமைன் + தவறான சேவையக முகவரி + எல்லா வரலாற்றையும் அழிக்கவும் + கடைசி 2 மணி நேரம் + வரலாறு அழிக்கப்பட்டது + இன்னும் புக்மார்க்குகள் இல்லை + மங்காவைப் படிக்கும்போது நீங்கள் புத்தகக்குறியை உருவாக்கலாம் + புக்மார்க்குகள் அகற்றப்பட்டன + மங்கா ஆதாரங்கள் இல்லை + மங்கா ஆதாரங்களை மங்கா ஆன்லைனில் படிக்க இயக்கவும் + சீரற்ற + தேர்ந்தெடுக்கப்பட்ட பிடித்த வகைகளை நீக்க விரும்புகிறீர்களா?\n அதில் உள்ள அனைத்து மங்காவும் இழக்கப்படும், இதை செயல்தவிர்க்க முடியாது. + வெளியேற மீண்டும் அழுத்தவும் + பயன்பாட்டிலிருந்து வெளியேற இரண்டு முறை மீண்டும் அழுத்தவும் + கிடைக்கிறது + பிடித்தவைகளிலிருந்து அகற்றப்பட்டது + விருப்பங்கள் + உள்ளடக்கம் கண்டுபிடிக்கப்படவில்லை அல்லது அகற்றப்படவில்லை + மறைநிலை பயன்முறை + அத்தியாயங்கள் இல்லை + தானியங்கி சுருள் + Ch %1$d/ டி pg + வாசகரில் செய்தி பட்டியைக் காட்டு + காமிக்ச் காப்பகம் + படங்களுடன் கோப்புறை + மங்காவை இறக்குமதி செய்கிறது + இறக்குமதி முடிந்தது + இடத்தை சேமிக்க நீங்கள் அசல் கோப்பை சேமிப்பகத்திலிருந்து நீக்கலாம் + பிழை விவரங்கள்: <br> <tt>%1$s </tt> <br> <br> 1. ஒரு வலை உலாவியில் மங்காவைத் திறக்க <a href =%2$s> ஐத் திறக்க முயற்சிக்கவும் </a> அதன் மூலத்தில் கிடைக்கிறது <br> 2. நீங்கள் <a href = kotatsu: // பற்றி> கோட்டாட்சுவின் அண்மைக் கால பதிப்பு </a> <br> 3 ஐப் பயன்படுத்துகிறீர்கள் என்பதை உறுதிப்படுத்திக் கொள்ளுங்கள். இது கிடைத்தால், டெவலப்பர்களுக்கு பிழை அறிக்கையை அனுப்பவும். + அண்மைக் கால மங்கா குறுக்குவழிகளைக் காட்டு + பக்க மாறுதல் திசையை வாசகர் பயன்முறையில் சரிசெய்ய வேண்டாம், இ. g. சரியான விசையை அழுத்துவது எப்போதும் அடுத்த பக்கத்திற்கு மாறுகிறது. இந்த விருப்பம் வன்பொருள் உள்ளீட்டு சாதனங்களை மட்டுமே பாதிக்கிறது + பணிச்சூழலியல் வாசகர் கட்டுப்பாடு + மாறுபாடு + சேமிக்கப்படாத மாற்றங்களைச் சேமிக்கவா அல்லது நிராகரிக்கவா? + நிராகரிக்கவும் + சாதனத்தில் எந்த இடமும் இல்லை + பக்க மாறுதல் ச்லைடரைக் காட்டு + வலை தொனி சூம் + ஆன்லைனில் மங்காவைப் படிக்க வைஃபை அல்லது மொபைல் நெட்வொர்க்கை இயக்கவும் + சேவையக பக்க பிழை (%1$d). தயவுசெய்து பின்னர் மீண்டும் முயற்சிக்கவும் + புதிய அத்தியாயங்களைப் பற்றிய தெளிவான தகவல்களும் + உள்ளடக்க முன் ஏற்றுதல் + மின்னோட்டமாக குறிக்கவும் + மொழி + பதிவுகளைப் பகிரவும் + பதிவை இயக்கவும் + பிழைத்திருத்த நோக்கங்களுக்காக சில செயல்களைப் பதிவுசெய்க. நீங்கள் என்ன செய்கிறீர்கள் என்று உங்களுக்குத் தெரியாவிட்டால் அதை இயக்க வேண்டாம் + வண்ணத் திட்டம் + கட்டம் பார்வையில் காண்பி + விவரம் + பணக்காரர் + இங்கே எதுவும் இல்லை + அடுத்து படிக்காத %s + மற்றொன்று + வாசிப்பு முன்னேற்றத்தைக் கண்காணிக்க, மங்கா விவரங்கள் திரையில் பட்டியல் → தடத்தைத் தேர்ந்தெடுக்கவும். + நிலையற்ற புதுப்பிப்புகளை அனுமதிக்கவும் + நிலையற்ற கட்டடங்களைப் பற்றிய அறிவிப்புகளைப் பெறுங்கள் + பதிவிறக்கம் தொடங்கியது + கிடைத்தது + அவற்றை மறுவரிசைப்படுத்த ஒரு பொருளைத் தட்டவும் பிடிக்கவும் + நீங்கள் ஒன்று அல்லது அதற்கு மேற்பட்ட .CBZ அல்லது .ZIP கோப்புகளைத் தேர்ந்தெடுக்கலாம், ஒவ்வொரு கோப்பும் ஒரு தனி மங்காவாக அங்கீகரிக்கப்படும். + காப்பகங்கள் அல்லது படங்களுடன் ஒரு கோப்பகத்தை நீங்கள் தேர்ந்தெடுக்கலாம். ஒவ்வொரு காப்பகமும் (அல்லது துணை அடைவு) ஒரு அத்தியாயமாக அங்கீகரிக்கப்படும். + வேகம் + ஒத்திசைவு அமைப்புகள் + சேவையக முகவரி + நீங்கள் சுய-ஓச்ட் ஒத்திசைவு சேவையகம் அல்லது இயல்புநிலை ஒன்றைப் பயன்படுத்தலாம். நீங்கள் என்ன செய்கிறீர்கள் என்று உங்களுக்குத் தெரியாவிட்டால் இதை மாற்ற வேண்டாம். + SSL பிழைகளை புறக்கணிக்கவும் + தானாகவே கண்ணாடியைத் தேர்வுசெய்க + கண்ணாடிகள் கிடைத்தால் பிழைகள் குறித்த மங்கா ஆதாரங்களுக்கான களங்களை தானாக மாற்றவும் + இடைநிறுத்தம் + மீண்டும் தொடங்குங்கள் + இடைநிறுத்தப்பட்டது + முடிக்கப்பட்டதை அகற்று + அனைத்தையும் ரத்துசெய் + வைஃபை வழியாக மட்டுமே பதிவிறக்கவும் + மொபைல் நெட்வொர்க்கிற்கு மாறும்போது பதிவிறக்குவதை நிறுத்துங்கள் + பரிந்துரை: %s + சில நேரங்களில் பரிந்துரைக்கப்பட்ட மங்காவுடன் அறிவிப்புகளைக் காட்டுங்கள் + மேலும் + இயக்கு + நன்றி இல்லை + அனைத்து செயலில் உள்ள பதிவிறக்கங்களும் ரத்து செய்யப்படும், ஓரளவு பதிவிறக்கம் செய்யப்பட்ட தரவு இழக்கப்படும் + உங்கள் பதிவிறக்கங்களின் வரலாறு நிரந்தரமாக நீக்கப்படும். பதிவிறக்கம் செய்யப்பட்ட கோப்புகள் எதுவும் பாதிக்கப்படாது + உங்களிடம் எந்த பதிவிறக்கங்களும் இல்லை + பதிவிறக்கங்கள் மீண்டும் தொடங்கப்பட்டுள்ளன + பதிவிறக்கங்கள் இடைநிறுத்தப்பட்டுள்ளன + பதிவிறக்கங்கள் அகற்றப்பட்டுள்ளன + பதிவிறக்கங்கள் ரத்து செய்யப்பட்டுள்ளன + தனிப்பயனாக்கப்பட்ட மங்கா பரிந்துரைகளைப் பெற விரும்புகிறீர்களா? + வகை + முகவரி + துறைமுகம் + பதிவிறக்கம் + படங்கள் உகப்பாக்கம் பதிலாள் + போக்குவரத்து பயன்பாட்டைக் குறைக்க மற்றும் முடிந்தால் பட ஏற்றுதலை விரைவுபடுத்த WSRV.NL சேவையைப் பயன்படுத்தவும் + வண்ணங்களை தலைகீழ் + பயனர்பெயர் + கடவுச்சொல் + தவறான துறைமுகம் எண் + பிணையம் + தரவு மற்றும் தனியுரிமை + முன்பு உருவாக்கப்பட்ட காப்புப்பிரதியை மீட்டமை + வெப்டூன் பயன்முறையில் சைகையில் பெரிதாக்க அனுமதிக்கவும் + திரையின் மேற்புறத்தில் தற்போதைய நேரத்தையும் வாசிப்பு முன்னேற்றத்தையும் காட்டுங்கள் + பக்க எண்களை கீழ் மூலையில் காண்பி + குறிப்பிட்ட டொமைனுக்கு மட்டுமே குக்கீகளை அழிக்கவும். பெரும்பாலான சந்தர்ப்பங்களில் அங்கீகாரத்தை செல்லாது + மொழிபெயர்ப்பு %s உடன் அனைத்து அத்தியாயங்களும் + முழு மங்கா + முதல் %s + மீட்டமை + படிக்காத அனைத்து அத்தியாயங்களும் (%s) + அத்தியாயங்களை கைமுறையாகத் தேர்ந்தெடுக்கவும் + தனிப்பயன் கோப்பகத்தைத் தேர்ந்தெடுங்கள் + இந்த கோப்பு அல்லது கோப்பகத்திற்கு உங்களுக்கு அணுகல் இல்லை + உள்ளக மங்கா கோப்பகங்கள் + விவரம் + இந்த மாதம் + குரல் தேடல் + தொடர்புடைய மங்கா + ஒளி + இருண்ட + வெள்ளை + கருப்பு + பின்னணி + தரவு மீட்டெடுக்கப்படவில்லை + சரியான காப்புப்பிரதி கோப்பை நீங்கள் தேர்ந்தெடுத்துள்ளீர்கள் என்பதை உறுதிப்படுத்திக் கொள்ளுங்கள் + வகைகளை நிர்வகிக்கவும் + மீட்டர் பிணையம் இணைப்புகளைப் பயன்படுத்தி பரிந்துரைகளைப் புதுப்பிக்க வேண்டாம் + மீட்டர் பிணையம் இணைப்புகளைப் பயன்படுத்தி புதிய அத்தியாயங்களை சரிபார்க்க வேண்டாம் + மங்கா தலைப்பு, வகை அல்லது மூல பெயரை உள்ளிடவும் + முன்னேற்றம் + சேர்க்கப்பட்டது + காட்டு + %s சரியாக வேலை செய்ய ஒரு கேப்ட்சா தீர்க்கப்பட வேண்டும் + மொழிகள் + தெரியவில்லை + முன்னேற்றத்தில் உள்ளது + NSFW ஐ முடக்கு + பல கோரிக்கைகள். பின்னர் மீண்டும் முயற்சிக்கவும் + பல கோரிக்கைகள். %s க்குப் பிறகு மீண்டும் முயற்சிக்கவும் + தொடர்புடைய மங்காவின் பட்டியலைக் காட்டு. சில சந்தர்ப்பங்களில் இது துல்லியமாக அல்லது காணாமல் போகலாம் + மேம்பட்ட + மங்கா பட்டியல் + தவறான தரவு திரும்பப் பெறப்படுகிறது அல்லது கோப்பு சிதைந்துள்ளது + சாதனத்தில் + கோப்பகங்கள் + முதன்மையான திரை பிரிவுகள் + மேலும் உருப்படிகளைச் சேர்க்க முடியாது + மேலே + மேலே நகர்த்தப்பட்டது + சிறிதாக்கு + பெரிதாக்கு + சூம் பொத்தான்களைக் காட்டு + கீழ் வலது மூலையில் சூம் கட்டுப்பாட்டு பொத்தான்களைக் காட்டலாமா? + திரையை தொடர்ந்து வைத்திருங்கள் + நீங்கள் மங்காவைப் படிக்கும்போது திரையை அணைக்க வேண்டாம் + கைவிடப்பட்டது + பேண்டலிங்கைக் குறைக்கிறது, ஆனால் செயல்திறனை பாதிக்கலாம் + 32-பிட் வண்ண பயன்முறை + பயன்பாட்டு புதுப்பிப்புக்குப் பிறகு புதிய ஆதாரங்களை பரிந்துரைக்கவும் + பயன்பாட்டைப் புதுப்பித்த பிறகு புதிதாக சேர்க்கப்பட்ட ஆதாரங்களை இயக்குமாறு தூண்டுதல் + பட்டியல் விருப்பங்கள் + பொருத்தமானது + வகைகள் + நிகழ்நிலை மாறுபாடு + காப்பு உருவாக்கும் அதிர்வெண் + ஒவ்வொரு 2 நாட்களுக்கும் + வாரத்திற்கு ஒரு முறை + மாதத்திற்கு இரண்டு முறை + மாதத்திற்கு ஒரு முறை + அவ்வப்போது காப்புப்பிரதிகளை இயக்கவும் + காப்புப்பிரதிகள் வெளியீட்டு அடைவு + கடைசி வெற்றிகரமான காப்புப்பிரதி: %s + எக்ச்%.1f எஃப் + மங்கா + என்டாய் + காமிக்ச் + இந்த பிரிவில் எந்த ஆதாரங்களும் கிடைக்கவில்லை, அல்லது இவை அனைத்தும் ஏற்கனவே சேர்க்கப்பட்டிருக்கலாம்.\n காத்திருங்கள் + உங்கள் வினவலால் கிடைக்கக்கூடிய மங்கா ஆதாரங்கள் எதுவும் இல்லை + பட்டியல் + ஆதாரங்களை நிர்வகிக்கவும் + கையேடு + கிடைக்கிறது: %1$d + NSFW ஆதாரங்களை முடக்கி, முடிந்தால் வயதுவந்த மங்காவை பட்டியலிலிருந்து மறைக்கவும் + இடைநிறுத்தப்பட்டது + நினைவக நுகர்வு குறைக்க (பீட்டா) + குறைந்த நினைவகத்தைப் பயன்படுத்த ஆஃப்ச்கிரீன் பக்கங்களின் தரத்தை குறைக்கவும் + பல வகைகளால் வடிகட்டுவது இந்த மங்கா மூலத்தால் ஆதரிக்கப்படவில்லை + பல மாநிலங்களால் வடிகட்டுவது இந்த மங்கா மூலத்தால் ஆதரிக்கப்படவில்லை + சேவையக பக்க தடுப்பதில் உங்களுக்கு சிக்கல்கள் இருந்தால், ஒவ்வொரு மங்கா மூலத்திற்கும் பதிவிறக்கம் மந்தநிலையை மூல அமைப்புகளில் தனித்தனியாக இயக்கலாம் + தவிர் + கிரேச்கேல் + உலகளவில் + இந்த மங்கா + இந்த அமைப்புகளை உலகளவில் அல்லது தற்போதைய மங்காவுக்கு மட்டுமே பயன்படுத்தலாம். உலகளவில் பயன்படுத்தப்பட்டால், தனிப்பட்ட அமைப்புகள் மீறப்படாது. + இடு + வகைகள் மற்றும் இருப்பிடங்கள் இரண்டாலும் வடிகட்டுவது இந்த மூலத்தால் ஆதரிக்கப்படவில்லை + வகைகள் மற்றும் மாநிலங்கள் இரண்டாலும் வடிகட்டுவது இந்த மூலத்தால் ஆதரிக்கப்படவில்லை + உங்களுக்கு ஏதேனும் சிக்கல்கள் இருந்தால் பதிவிறக்கத்தைத் தொடங்க உதவலாம் + நீங்கள் இயக்க விரும்பும் உள்ளடக்க ஆதாரங்களைத் தேர்ந்தெடுக்கவும். இதை பின்னர் அமைப்புகளிலும் கட்டமைக்க முடியும் + கணக்கை ஒத்திசைக்க உள்நுழைக + இந்த வகை முதன்மையான திரையில் இருந்து மறைக்கப்பட்டது மற்றும் பட்டியல் → வகைகளை நிர்வகிக்க முடியும் + தொகுதி %d + அறியப்படாத தொகுதி + உங்கள் வாசிப்பு முன்னேற்றம் காப்பாற்றப்படாது + செங்குத்து + கடைசியாக படித்தார் + மெனுவைக் காட்டு + இடைமுகம் ஐக் காட்டு/மறைக்க + முந்தைய அத்தியாயம் + அடுத்த அத்தியாயம் + முந்தைய பக்கம் + அடுத்த பக்கம் + வாசகர் செயல்கள் + தட்டக்கூடிய திரை பகுதிகளுக்கான செயல்களை உள்ளமைக்கவும் + தொகுதி பொத்தான்களை இயக்கவும் + பக்கங்களை மாற்றுவதற்கு தொகுதி பொத்தான்களைப் பயன்படுத்தவும் + செயலைத் தட்டவும் + நீண்ட குழாய் நடவடிக்கை + எதுவுமில்லை + இயல்புநிலை மதிப்புகளுக்கு அமைப்புகளை மீட்டமைக்கவா? இந்த செயலை செயல்தவிர்க்க முடியாது. + இயற்கை நோக்குநிலையில் (பீட்டா) இரண்டு பக்கங்கள் தளவமைப்பைப் பயன்படுத்தவும் + இயல்புநிலை வெப்டூன் பெரிதாக்கவும் + முழுத்திரை பயன்முறை + கணினி நிலை மற்றும் வழிசெலுத்தல் பார்களை மறைக்கவும் + மதிப்பிடப்பட்ட வாசிப்பு நேரத்தைக் காட்டு + நேர மதிப்பீட்டு மதிப்பு துல்லியமாக இருக்கலாம் + பரிந்துரைகள் நற்பொருத்தம் முடக்கப்பட்டுள்ளது + புதிய அத்தியாயங்களைச் சரிபார்ப்பது முடக்கப்பட்டுள்ளது + வழிசெலுத்தல் பட்டியில் லேபிள்களைக் காட்டு + பக்கங்களைச் சேமித்தல் + ஒவ்வொரு முறையும் இலக்கு டி.ஐ.ஆரைக் கேளுங்கள் + இயல்புநிலை பக்கம் கோப்பகத்தை சேமிக்கவும் + வரலாற்றிலிருந்து அகற்று + இடம் + விருப்பமான பதிவிறக்க வடிவம் + தானியங்கி + ஒற்றை சிபிஇசட் கோப்பு + பல சிபிஇசட் கோப்புகள் + புள்ளிவிவரங்களைப் படித்தல் + மற்ற மங்கா + ஒரு நிமிடத்திற்கும் குறைவாக + புள்ளிவிவரங்கள் + தெளிவான புள்ளிவிவரங்கள் + புள்ளிவிவரங்கள் அழிக்கப்பட்டன + அனைத்து வாசிப்பு புள்ளிவிவரங்களையும் அழிக்க விரும்புகிறீர்களா? இந்த செயலை செயல்தவிர்க்க முடியாது. + வாரம் + மாதம் + எல்லா நேரமும் + நாள் + மூன்று மாதங்கள் + தேர்ந்தெடுக்கப்பட்ட காலத்திற்கு புள்ளிவிவரங்கள் எதுவும் இல்லை + பக்கங்கள் படிக்க: %s + மாற்று + இடம்பெயர்வு + \"%2$s\" இலிருந்து \"%1$s\" உங்கள் வரலாறு மற்றும் பிடித்தவைகளில் \"%4 $ S\" இலிருந்து \"%3$s\" உடன் மாற்றப்படும் (இருந்தால்) + மங்கா இடம்பெயர்வு + இடம்பெயர்வு முடிந்தது + வாசிப்பு அத்தியாயங்களை நீக்கு + அத்தியாயங்கள் எதுவும் நீக்கப்படவில்லை + %1$s ஐ அகற்றி, %2$s ஐ அழித்தது + உள்ளக சேமிப்பகத்திலிருந்து நீங்கள் ஏற்கனவே படித்த அத்தியாயங்களை நீக்கவும் + இது உங்கள் உள்ளக சேமிப்பகத்திலிருந்து வாசிக்கப்பட்டதாக குறிக்கப்பட்ட அனைத்து அத்தியாயங்களையும் நிரந்தரமாக நீக்கும். நீங்கள் அதை பின்னர் மீண்டும் ஏற்றலாம், ஆனால் இறக்குமதி செய்யப்பட்ட அத்தியாயங்கள் என்றென்றும் இழக்கப்படலாம் + வாசிப்பு அத்தியாயங்களை தானாக நீக்குங்கள் + பயன்பாடு தொடங்கும் போது இயங்கும் + மொழிபெயர்ப்புகளால் பிரிக்கப்படுகிறது + ஒரு பட்டியலில் இல்லாமல், வெவ்வேறு மொழிபெயர்ப்புகளுடன் தனித்தனியாக அத்தியாயங்களைக் காட்டு + பழமையானது + நீண்ட காலத்திற்கு முன்பு படியுங்கள் + படிக்காதது + மூலத்தை இயக்கு + இந்த மங்கா மூலத்தை ஆதரிக்கவில்லை + பக்கங்கள் சிறு உருவங்களைக் காட்டு + விவரங்கள் திரையில் \"பக்கங்கள்\" தாவலை இயக்கவும் + சேவையகத்திலிருந்து தரவு எதுவும் பெறப்படவில்லை + சரியான கோட்டாட்சு காப்பு கோப்பைத் தேர்ந்தெடுக்கவும் + %d h + %d மீ + %d கள் + %1$d h %2$d மீ + %1$d m %2$d கள் + சரிசெய்யவும் + வெளிப்புற சேமிப்பகத்தில் மங்காவை அணுக இசைவு இல்லை + கடைசியாக பயன்படுத்தப்பட்டது + புதுப்பிக்கப்பட்டதைக் காட்டு + வெப்டூன் பயன்முறையில் இடைவெளிகள் + வெப்டூன் பயன்முறையில் பக்கங்களுக்கு இடையில் செங்குத்து இடைவெளிகளைக் காட்டு + குறைவாக அடிக்கடி + மேலும் அடிக்கடி + காசோலையின் அதிர்வெண் + முள் வழிசெலுத்தல் இடைமுகம் + வழிசெலுத்தல் பட்டியை மறைக்க வேண்டாம் மற்றும் சுருளில் தேடல் + பரிந்துரைகளைத் தேடுங்கள் + அண்மைக் கால வினவல்கள் + பரிந்துரைக்கப்பட்ட வினவல்கள் + நீங்கள் சேவையகத்தால் தடுக்கப்பட்டுள்ளீர்கள். வேறு பிணைய இணைப்பைப் பயன்படுத்த முயற்சிக்கவும் (VPN, பதிலாள், முதலியன) + முடக்கு + ஆதாரங்கள் முடக்கப்பட்டன + இணைப்பு காசோலையை முடக்கு + பிணையம் வளங்களை அணுகும்போது SSL தொடர்பான சிக்கல்களை எதிர்கொண்டால் SSL சான்றிதழ்கள் சரிபார்ப்பை முடக்கலாம். இது உங்கள் பாதுகாப்பை பாதிக்கலாம். இந்த அமைப்பை மாற்றிய பின் பயன்பாட்டு மறுதொடக்கம் தேவை. + உங்களிடம் சிக்கல்கள் இருந்தால் இணைப்பு காசோலையைத் தவிர்க்கவும் (எ.கா. பிணையம் இணைக்கப்பட்டிருந்தாலும் இணைப்பில்லாத பயன்முறையில் செல்வது) + NSFW அறிவிப்புகளை முடக்கு + NSFW MANGA புதுப்பிப்புகள் பற்றிய அறிவிப்புகளைக் காட்ட வேண்டாம் + புதிய அத்தியாயங்கள் பதிவை சரிபார்க்கிறது + அனைத்து மொழிகளும் + மறைநிலை பயன்முறையில் இருக்கும்போது தடுக்கவும் + பயிர் பக்கங்கள் + முள் + Unpin + மூல பின் + சான்று இணைக்கப்படாதது + ஆதாரங்கள் இணைக்கப்படாதவை + ஆதாரங்கள் பொருத்தப்பட்டுள்ளன + அண்மைக் கால ஆதாரங்கள் + விழுக்காடு படித்தது + விழுக்காடு இடது + அத்தியாயங்கள் படித்தன + அத்தியாயங்கள் எஞ்சியுள்ளன + வெளிப்புற/சொருகி + பொருந்தாத சொருகி அல்லது உள் பிழை. சொருகி மற்றும் கோட்டாட்சுவின் அண்மைக் கால பதிப்பைப் பயன்படுத்துகிறீர்கள் என்பதை உறுதிப்படுத்திக் கொள்ளுங்கள் + சொருகி பிழை: %s\n சொருகி மற்றும் கோட்டாட்சுவின் அண்மைக் கால பதிப்பைப் பயன்படுத்துகிறீர்கள் என்பதை உறுதிப்படுத்திக் கொள்ளுங்கள் + இணைப்பு சரி + நீண்ட காலத்திற்கு முன்பே புதுப்பிக்கப்பட்டது + பிரபலமற்ற + குறைந்த மதிப்பீடு + %s உடன் ஒருங்கிணைப்பை அமைக்க உள்நுழைக. இது உங்கள் மங்கா வாசிப்பு முன்னேற்றம் மற்றும் நிலையை கண்காணிக்க உங்களை அனுமதிக்கும் + இந்த செயல்பாடு சோதனை. தரவு இழப்பைத் தவிர்க்க உங்களுக்கு காப்புப்பிரதி இருப்பதை உறுதிப்படுத்திக் கொள்ளுங்கள் + பின்னணி பதிவிறக்கங்கள் + புதிய அத்தியாயங்களைப் பதிவிறக்கவும் + பதிவிறக்கம் செய்யப்பட்ட அத்தியாயங்களுடன் மங்கா + மங்கா \"%1$s\" (%2$s) \"%3$s\" (%4$s) உடன் மாற்றப்பட்டது + மங்காவை சரிசெய்தல் + வெற்றிகரமாக சரி செய்யப்பட்டது + \"%s\" க்கு பிழைத்திருத்தம் தேவையில்லை + \"%s\" க்கு மாற்று வழிகள் எதுவும் கிடைக்கவில்லை + இந்த செயல்பாடு தேர்ந்தெடுக்கப்பட்ட மங்காவிற்கான மாற்று ஆதாரங்களைக் கண்டறியும். பணி சிறிது நேரம் எடுக்கும் மற்றும் பின்னணியில் தொடரும் + நாவல் + மன்உவா + மன்அ்வா + அண்மைக் காலத்தில் சேர்க்கப்பட்டது + நீண்ட காலத்திற்கு முன்பு சேர்க்கப்பட்டது + இந்த மணிநேரம் பிரபலமானது + இந்த மாதம் பிரபலமானது + இந்த ஆண்டு பிரபலமானது + அசல் மொழி + மக்கள்தொகை + சவுன் + சோசோ + அவரது + சோசி + இந்த மூலமானது வடிப்பான்களுடன் தேடலை ஆதரிக்காது. உங்கள் வடிப்பான்கள் அழிக்கப்பட்டுவிட்டன + ஒரு காட்சி + டூசின்சி + செல்லுலார் நெட்வொர்க்கில் பதிவிறக்கங்களை அனுமதிக்கவா? + அனுமதிக்க வேண்டாம் + எப்போதும் அனுமதிக்கவும் + ஒரு முறை அனுமதிக்கவும் + ஒவ்வொரு முறையும் கேளுங்கள் + திரை நோக்குநிலை + உருவப்படம் + நிலப்பரப்பு + காப்புப்பிரதிகளின் அதிகபட்ச எண்ணிக்கை + பழைய காப்புப்பிரதிகளை நீக்கவும் + சேமிப்பக இடத்தை சேமிக்க பழைய காப்பு கோப்புகளை தானாக நீக்கவும் + இணைப்புகளைக் கையாளவும் + வெளிப்புற பயன்பாடுகளிலிருந்து மங்கா இணைப்புகளைக் கையாளவும் (எ.கா. வலை உலாவி). பயன்பாட்டின் கணினி அமைப்புகளில் நீங்கள் அதை கைமுறையாக இயக்க வேண்டியிருக்கலாம் + இந்த மூலத்தைத் தொடர ஒரு கேப்ட்சாவை தீர்க்க வேண்டும் + செயல்வரம்பு + மூலம் + மொழிபெயர்ப்பு + ச்லைடரைக் காட்டு + மறைநிலை + தொலைநிலை புரவலன் மூலம் இணைப்பு மீட்டமைப்பு + சோதனை செய்தி + அரட்டை ஐடி அமைக்கப்படவில்லை + தந்தி அரட்டை ஐடி + டெலிகிராம் போட் திறக்கவும் + டெலிகிராமில் காப்புப்பிரதிகளை அனுப்பவும் + சோதனை இணைப்பு + காப்புப்பிரதிகள் அனுப்பப்பட வேண்டிய அரட்டை ஐடியை உள்ளிடவும் + கோட்டாட்சு காப்பு போட் உடன் அரட்டையடிக்க அழுத்தவும் + தரவுத்தளத்தை அழிக்கவும் + பயன்படுத்தப்படாத மங்கா பற்றிய தகவல்களை நீக்கு + அனைத்து மங்கா ஆதாரங்களையும் இயக்கவும் + கிடைக்கக்கூடிய அனைத்து மங்கா ஆதாரங்களும் நிரந்தரமாக இயக்கப்படும் + கோடோமோ + diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e419c9b1d..41ac5c818 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -41,7 +41,7 @@ Sayfalar Temizle Kaldır - “%s” yerel depolamadan silindi + \"%s\" yerel depolamadan silindi Sayfayı kaydet Görseli paylaş Popüler @@ -96,7 +96,7 @@ Sonra oku Sayfa animasyonu Kullanılabilir depolama alanı yok - “%s” cihazdan kalıcı olarak silinsin mi? + \"%s\" cihazdan kalıcı olarak silinsin mi? Arama geçmişini temizle Burası biraz boş… Ekranı döndür @@ -366,7 +366,7 @@ Eşitleme ayarları Sunucu adresi Kendi eşitleme sunucunuzu veya varsayılan bir sunucuyu kullanabilirsiniz. Ne yaptığınızdan emin değilseniz bunu değiştirmeyin. - Yedek bağlantılar mevcutsa hatalarda manga kaynakları için bağlantıları otomatik olarak değiştir + Hata durumlarında manga kaynakları için yedek bağlantılar mevcutsa otomatik olarak bu bağlantıları kullan Mobil ağa geçerken indirmeyi durdur Bitirilenleri kaldır Hepsini iptal et @@ -753,4 +753,52 @@ Bağlantıları aç Harici uygulamalardaki (örn. web tarayıcısı) manga bağlantılarını uygulamada açın. Uygulamanın sistem ayarlarından bunu aktifleştirmeniz gerekebilir Bu kaynağın kullanılabilmesi için bir captcha çözülmesi gerekiyor - \ No newline at end of file + Yazar + Puan + Kaynak + Çeviri + Kaydırıcıyı göster + Gizli Mod + Bağlantı ana bilgisayar tarafından sıfırlandı + Mesaj testi + Telegram sohbet ID\'si + Telegram botunu aç + Veri tabanını temizle + Kullanılmayan mangalar ile ilgili bilgileri silin + API durumunu kontrol et + Sohbet ID\'si ayarlanmadı + Yedekleri Telegram\'a gönder + Bağlantıyı test et + Yedeklerin gönderileceği sohbetin ID\'sini girin + Kotatsu Yedek Botu ile sohbeti açmak için dokunun + Bütün manga kaynaklarını etkinleştir + Bütün kullanılabilir manga kaynakları kalıcı olarak etkinleştirilecektir + Bütün kaynaklar etkinleştirildi + Şeffaf bilgi çubuğu + Yedek arka planda geri yüklenecektir + Yedek geri yükleniyor + Sayfa değiştirme kaydırıcısı + Ekran yönü kilitlendi + Ekran yön kilidi açıldı + Alt çubukta okuyucu kontrolleri + Bölümler ve sayfalar + Basit + Genel arama + Her yerde ara + Listelerde rozetler + Captcha bildirimlerini kapat + Bu kaynak için CAPTCHA doğrulaması bildirimleri almayacaksınız ancak bu, arka plan işlemlerinin çalışmamasına neden olabilir (yeni bölümleri kontrol etme, önerileri alma vb.) + Cilt %1$s Bölüm %2$s + Bölüm %s + İsimsiz bölüm + Devre dışı bırakılmış kaynaklarda ara + Hata ayrıntıları + Kullandığınız Kotatsu versiyonu güncel değil gibi görünüyor. Bütün hata düzeltmelerini almak için lütfen en son sürümü indirin. + Manganın kaynakta var olduğundan emin olmak için bir tarayıcıda açmayı deneyin. + Geliştiricilere bir hata bildiriminde bulunabilirsiniz. Sorunu araştırmamıza ve çözmemize yardımcı olacaktır. + %s üzerinde manganın bağlantısı + Kotatsu üzerinde manganın bağlantısı + Tarayıcı geçmişini temizle + Önbellek ve çerezler gibi tarayıcı verilerini temizleyin. Uyarı: Manga kaynakları için yapılan doğrulamalar geçersiz kalabilir + Dosya yazma izni yok + diff --git a/app/src/main/res/values-vi/strings.xml b/app/src/main/res/values-vi/strings.xml index 5491bcaa2..e574cbe0a 100644 --- a/app/src/main/res/values-vi/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -75,14 +75,14 @@ Kích thước: %s Xoay màn hình Cập nhật danh sách sẽ bắt đầu sớm - Tìm kiếm bản cập nhật mới + Kiểm tra tập / chương truyện mới Không kiểm tra Nhập mật khẩu Hỏi mật khẩu khi khởi động Kotatsu Nhập lại mật khẩu Giới thiệu Phiên bản %s - Kiểm tra cập nhật + Chạm để kiểm tra bản cập nhật mới Không có bản cập nhật Phải sang trái Danh mục mới @@ -96,7 +96,7 @@ Bạn có thể tạo bản sao lưu lịch sử, danh sách yêu thích và khôi phục nó Mới đây Hôm qua - Cấu hình được chọn sẽ được lưu lại cho truyện này + Cài đặt được chọn sẽ chỉ lưu lại cho truyện này Im lặng Yêu cầu xác thực CAPTCHA Xác thực @@ -307,7 +307,7 @@ Tự động cuộn Quá trình nhập sẽ bắt đầu sớm Hiện thanh thông tin truyện trong trình đọc - Thư mục với hình ảnh + Từ thư mục chứa hình ảnh / dữ liệu truyện Nhập hoàn tất Bạn có thể xoá file gốc khỏi bộ nhớ để tiết kiệm dung lượng Đặt lại @@ -396,7 +396,7 @@ Tự động chuyển sang tên miền dự phòng của nguồn đọc (nếu có) khi gặp lỗi Đã tạm dừng Proxy tối ưu hình ảnh - Sử dụng dịch vụ wsrv.nl để giảm dung lượng ảnh và tăng tốc quá trình tải ảnh nếu có thể + Sử dụng dịch vụ wsrv.nl để giảm dung lượng ảnh và tăng tốc quá trình tải ảnh nếu khả thi Khôi phục từ bản sao lưu đã được tạo trước đó Hiển thị thời gian hiện tại và tiến trình đọc ở góc bên trên màn hình Toàn bộ truyện @@ -422,7 +422,7 @@ Nhận thông báo về các bản dựng không ổn định Truyện tranh liên quan Không cập nhật đề xuất bằng kết nối mạng có đo lường - Kho lưu trữ truyện tranh + Từ tệp đã nén dữ liệu truyện Bạn có thể chọn một thư mục để làm kho lưu trữ / hình ảnh. Mỗi kho lưu trữ (hoặc thư mục con) sẽ được coi là một chương. Tìm kiếm bằng giọng nói Điều khiển trình đọc tiện dụng @@ -460,11 +460,11 @@ Manga Hentai Comics - Tắt các nguồn NSFW và ẩn các manga người lớn từ danh sách nếu có thể + Tắt các nguồn NSFW và ẩn các manga người lớn từ danh sách (nếu khả thi) Giảm tiêu tốn bộ nhớ (beta) Giảm chất lượng các trang chưa hiển thị để dùng ít bộ nhớ hơn Nguồn manga - Sẽ có sau: %1$d + Hiện đang có sẵn: %1$d Nhập tên thể loại Ngày sao lưu: %s Cài đặt có thể được áp dụng cho toàn bộ nguồn hoặc chỉ nguồn manga này. Nếu áp dụng cho toàn bộ, các nguồn đã cài đặt riêng không bị ảnh hưởng. @@ -547,9 +547,7 @@ An toàn Tab mặc định Đánh dấu đã hoàn thành - Đánh dấu manga đã chọn là Đã đọc? -\n -\nCảnh báo: Tiến trình đọc hiện tại sẽ bị mất. + Đánh dấu manga đã chọn là manga đã đọc xong? \n \nCảnh báo: Tiến trình đọc hiện tại của bạn sẽ bị xoá và sẽ được đánh dấu là đã hoàn thành. Chương tiếp theo Trang trước Trang tiếp theo @@ -661,7 +659,7 @@ Plugin bị lỗi hoặc không phù hợp. Hãy đảm bảo rằng bạn đang sử dụng phiên bản mới nhất của Kotatsu hoặc của Plugin đó Không có bộ truyện nào phù hợp với bộ lọc của bạn ở đây ¯\\_(ツ)_/¯ Kết nối rất OK ¯\\_(ツ)_/¯ - Cấu hình Proxy này không hợp lệ + Cài đặt Proxy này của bạn không hợp lệ Hiện bộ lọc nhanh Cung cấp khả năng lọc danh sách Manga dựa vào dữ liệu cụ thể Địa chỉ máy chủ này không hợp lệ @@ -705,7 +703,7 @@ Nổi bật trong tháng Ngôn ngữ gốc Năm - Nhân khẩu học + Thể loại đối tượng Shounen Shoujo Seinen @@ -749,7 +747,56 @@ Đã lưu trang Xóa bản sao lưu cũ Tự động xóa các bản sao lưu cũ để tiết kiệm bộ nhớ của thiết bị - Xử lí liên kết (Link) - Xử lý các liên kết (Link) manga từ các ứng dụng bên ngoài (Ví dụ: trình duyệt web,...). Có thể bạn cũng cần phải bật thủ công trong cài đặt hệ thống + Xử lý liên kết (Link) + Xử lý các liên kết (Link) truyện từ các ứng dụng bên ngoài (Ví dụ: Chrome,...). Có thể bạn cũng cần phải bật thủ công trong cài đặt hệ thống E-mail - \ No newline at end of file + Nguồn đang yêu cầu xác thực CAPTCHA để có thể tiếp tục sử dụng + Tác giả + Đánh giá + Nguồn + Bản dịch + Ẩn danh + Hiện thanh trượt + Tình trạng: 200 OK + Kiểm tra API + Bạn chưa thiết lập Chat ID của Telegram + Chat ID của Telegram + Mở Bot Telegram + Kết nối đã được đặt lại bởi máy chủ + Nhập Chat ID từ Telegram của bạn, nơi mà Bot sẽ gửi bản sao lưu dữ liệu của ứng dụng cho bạn + Kiểm tra kết nối + Gửi bản sao lưu dữ liệu vào Telegram + Ấn vào đây để mở đoạn Chat với Kotatsu Backup Bot (cần cài đặt Telegram) + Xóa database + Xoá thông tin dữ liệu về manga chưa từng được sử dụng (cache) + Toàn bộ nguồn đọc đã được bật + Tất cả nguồn đọc / nguồn truyện mà App có sẵn sẽ được bật vĩnh viễn + Bật toàn bộ nguồn đọc có sẵn + Làm trong suốt thanh trạng thái ở giao diện đọc truyện + Đang khôi phục sao lưu + Duyệt chương và trang đọc + Thanh trượt chuyển trang + Xoay màn hình đã được khóa lại + Hướng xoay màn hình đã được mở khóa + Quá trình khôi phục bản sao lưu này sẽ được thực hiện trong tiến trình nền (chạy ngầm) + Tùy chỉnh nút điều khiển ở thanh công cụ phía dưới giao diện đọc + Hiển thị biểu tượng trong thư viện + Đơn giản + Tìm kiếm ở bất cứ đâu + Tìm kiếm chung + Tắt thông báo về Captcha + Bạn sẽ không nhận được thông báo về việc giải CAPTCHA cho nguồn này nhưng điều này cũng có thể dẫn đến việc phá vỡ quá trình hoạt động nền đối với nguồn này (như kiểm tra chương / tập truyện mới, đưa ra gợi ý,...) + Chi tiết lỗi + Bạn có thể báo cáo lỗi cho các nhà phát triển của Kotatsu. Nó sẽ rất hữu ích để các nhà phát triển có thể tìm ra và xử lý lỗi đó ở các phiên bản kế tiếp. + Có vẻ như bạn đang sử dụng một phiên bản \"siêu cấp cổ đại\" của Kotatsu. Hãy cập nhật / cài đặt phiên bản mới nhất để nhận được các bản vá lỗi cho Kotatsu. + Hãy đảm bảo rằng truyện này vẫn xuất hiện ở nguồn đọc đó bằng cách tìm và mở nó trên trình duyệt Web của bạn ¯\\_(ツ)_/¯. + Tìm kiếm với những nguồn đang bị vô hiệu hóa + Vol %1$s Chương %2$s + Chương %s + Chương không có tên + Liên kết manga trên Kotatsu + Liên kết manga với nguồn %s + Xóa dữ liệu của trình duyệt + Điều này sẽ xóa những dữ liệu như cache và cookies. Lưu ý: Xác thực (đăng nhập) đối với nguồn đọc có thể sẽ không còn hợp lệ nữa + Không có quyền để ghi một tệp tin mới + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 52d9ec5ad..61b4e7023 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -28,7 +28,7 @@ 开始验证 今天 清除 Cookies - 发现了新的可用漫画源 + 发现了新的可用图源 根据你的喜好推荐漫画 所有数据都在本地分析,不会发送至任何地方 从不 @@ -53,7 +53,7 @@ 结果为空 暂无历史记录 阅读 - 暂无漫画收藏 + 暂无收藏漫画 收藏此漫画 添加新分类 添加 @@ -314,7 +314,7 @@ 更新 %1$s - %2$s 没有章节或已被删除 - 不修改翻页方向到已经设置的阅读模式,例:按下右键总是翻到下一页,此设置只对硬件设备有效 + 不修改翻页方向到已经设置的阅读模式(例:按下右键总是翻到下一页,此设置只对硬件设备有效) 简易操作 长按应用图标显示最近阅读的漫画 显示最近阅读漫画的快捷方式 @@ -357,7 +357,7 @@ 接收测试版本的更新通知 下载已开始 UserAgent 标识 - 重启程序后更改 + 重启程序后生效 点击并按住项目排序 知道了 速度 @@ -419,7 +419,7 @@ 用户名 所有未读章节 (%s) 授权 (可选) - 前%s + 前 %s 下载进度 后续未读的 %s 尽可能使用 wsrv.nl 代理服务减少流量使用并加快图片加载 @@ -444,7 +444,7 @@ 目录 分类管理 浅色 - 输入漫画标题、分类或图源名称 + 输入漫画标题、风格或图源名称 主页底部导航栏 高级 深色 @@ -500,7 +500,7 @@ 跳过 备份日期:%s 内容分级 - 屏蔽分类 + 风格屏蔽 全年龄 R15 R18 @@ -518,16 +518,16 @@ %1$d 个可用 关闭含有成人内容的图源并尽可能从列表中隐藏成人漫画 x%.1f - 此图源不支持同时按分类和区域筛选 - 此图源不支持同时按分类和状态筛选 - 开始输入分类名称 + 此图源不支持同时按风格和区域筛选 + 此图源不支持同时按风格和状态筛选 + 请输入风格名称 休刊中 降低内存占用 (测试) 降低当前画面外的页面质量以减少内存占用 也许能帮助解决下载过程中的相关问题 此图源不支持搜索 状态 - 此图源不支持按多个分类筛选 + 此图源不支持按多个风格筛选 此图源不支持按多个状态筛选 关联 标记为已读 @@ -595,7 +595,7 @@ 从本地设备中删除已读章节来释放存储空间 漫画换源 即将换源 - 换源完成 + 换源成功 切换为网格视图 将会删除所有储存在本地并被标记为已读的章节,可之后再重新下载,但已导入的章节将永久丢失 自动删除已读章节 @@ -609,7 +609,7 @@ 开启图源 不支持此漫画图源 显示页面缩略图 - 开启后会在漫画详情页显示“页面”标签栏 + 在漫画详情页显示“页面”标签栏 未从服务器接收到任何数据 请选择正确的 Kotatsu 备份文件 %d 分 @@ -644,7 +644,7 @@ 最新 开启无痕模式时禁止 所有语言 - 图片服务器偏好 + 图片质量 裁剪页面 置顶 取消置顶 @@ -734,7 +734,7 @@ 可在章节列表长按选择章节下载。 全部 总是允许 - 数据流量下载 + 使用数据流量下载 允许一次 每次询问 拒绝 @@ -753,4 +753,48 @@ 处理来自外部程序的漫画链接(如 Web 浏览器), 可能需要在程序的系统设置中手动开启 电子邮箱 此图源需要通过验证以继续操作 - \ No newline at end of file + 作者 + 评分 + 来源 + 翻译 + 无痕 + 显示滑块 + 远程主机重置了连接 + 测试消息 + 检查 API 是否可用 + 未设置聊天ID + Telegram 聊天ID + 打开 Telegram 机器人 + 用 Telegram 发送备份 + 输入接收备份的 Telegram 聊天 ID + 测试连接 + 按下打开和 Kotasu Backup 机器人的聊天 + 清除数据库 + 删除未使用的漫画信息 + 开启所有漫画图源 + 已启动所有漫画图源 + 所有图源将会被启动 + 顶部信息栏透明 + 正在恢复备份 + 将在后台恢复备份 + 页面切换滑动条 + 屏幕方向已锁定 + 屏幕方向已解锁 + 章节与页面 + 阅读器底部控制栏 + 关闭验证码通知 + 开启后将不会在本图源收到需要通过验证码的通知,但会导致后台进程出现异常 (章节更新和推荐漫画等) + 全局搜索 + 显示列表小图标 + 第 %1$s 卷 第 %2$s 章 + 第 %s 章 + 未命名章节 + 同样在已关闭的图源中搜索 + 错误详细信息 + 将以在浏览器中打开漫画的方式检查漫画在图源中是否能正常打开 + 可向开发者提交错误报告,将会帮助我们定位并修复错误 + 你的 Kotatsu 版本过旧,请下载最新版本以修复问题 + 清除浏览器数据 + 写入文件权限被禁止 + 点击后清除浏览器(如缓存、Cookies)数据,警告:图源授权可能变为无效 + diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index fa5ea6aa5..d39a4cf1d 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -124,4 +124,16 @@ @string/portrait @string/landscape + + @string/prev_chapter + @string/next_chapter + @string/pages_slider + @string/chapters_and_pages + @string/screen_orientation + @string/save_page + + + @string/favourites + @string/saved_manga + diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index fafb1608f..de4b48348 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -10,6 +10,7 @@ + @@ -132,6 +133,7 @@ + @@ -140,6 +142,7 @@ + @@ -175,9 +178,21 @@ + + + + + + + + + + + + diff --git a/app/src/main/res/values/colors.xml b/app/src/main/res/values/colors.xml index 1f6abee57..61c1d843c 100644 --- a/app/src/main/res/values/colors.xml +++ b/app/src/main/res/values/colors.xml @@ -15,6 +15,7 @@ #1976D2 #424242 #99000000 + #C8FFFFFF #E65100 #FFFFFF #388E3C diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml index 1c260acf0..9a18c5eb1 100644 --- a/app/src/main/res/values/constants.xml +++ b/app/src/main/res/values/constants.xml @@ -7,7 +7,6 @@ https://kotatsu.app/manuals/guides/getting-started/ https://bugs.kotatsu.app/report org.kotatsu.sync - https://sync.kotatsu.app KotatsuApp/Kotatsu Mw6F0tPEOgyV7F9U9Twg50Q8SndMY7hzIOfXg0AX_XU euBMt1GGRSDpVIFQVPxZrO7Kh6X4gWyv0dABuj4B-M8 @@ -20,6 +19,8 @@ kgpuhoNJpSsQDCwu org.koitharu.kotatsu.history org.koitharu.kotatsu.favourites + 7455491254:AAGYJKgpP1DZN3d9KZfb8tvtIdaIMxUayXM + kotatsu_backup_bot -1 1 @@ -43,8 +44,9 @@ 1 - @string/sync_url_default https://moe.shirizu.org + https://sync.kotatsu.app + http://54.254.71.100 http://86.57.183.214:8081 @@ -75,4 +77,9 @@ 1 2 + + + 4 + 1 + diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml index 511e2291c..1200328b7 100644 --- a/app/src/main/res/values/dimens.xml +++ b/app/src/main/res/values/dimens.xml @@ -9,7 +9,7 @@ 8dp 12dp - 62dp + 0dp 8dp 8dp @@ -34,7 +34,7 @@ 80dp 8dp 24dp - 92dp + 120dp 56dp 142dp diff --git a/app/src/main/res/values/ids.xml b/app/src/main/res/values/ids.xml index 582c3fc28..e8504f092 100644 --- a/app/src/main/res/values/ids.xml +++ b/app/src/main/res/values/ids.xml @@ -2,8 +2,6 @@ - - diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 172fbbc50..b37edda5e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -8,7 +8,7 @@ Network error Details Chapters - List + Liste Detailed list Grid List mode @@ -769,5 +769,54 @@ Handle manga links from external applications (e.g. web browser). You may also need to enable it manually in the application\'s system settings Email This source requires solving a captcha to continue + Author + Rating + Source + Translation + %1$s (%2$s) + Show slider + + Incognito Connection reset by remote host + Check if API works + Test message + Chat ID is not set + Telegram chat ID + Open the Telegram bot + Send backups in Telegram + Test connection + Enter the chat ID where backups should be sent + Press to open chat with Kotatsu Backup Bot + Clear database + Delete information about manga that is not used + Enable all manga sources + All available manga sources will be enabled permanently + All sources are enabled + Transparent reader information bar + The backup will be restored in the background + Restoring backup + Reader controls in bottom bar + Chapters and pages + Page switch slider + Screen rotation has been locked + Screen rotation has been unlocked + Badges in lists + Search everywhere + Simple + Global search + Disable captcha notifications + You will not receive notifications about solving CAPTCHA for this source but this can lead to breaking background operations (checking for new chapters, obtaining recommendations, etc) + Vol %1$s Chapter %2$s + Chapter %s + Unnamed chapter + Search through disabled sources + Error details + Try to open manga in a web browser to ensure it is available on its source. + It looks like your version of Kotatsu is out of date. Please install the latest version to get all available fixes. + You can submit a bug report to the developers. This will help us investigate and fix the issue. + Link to manga on %s + Link to manga in Kotatsu + Clear browser data + Clear browser data such as cache and cookies. Warning: Authorization in manga sources may become invalid + Does not have permission to write a file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 9fa061b69..5b741b49e 100644 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -14,18 +14,6 @@ - - - - + + + + + + @@ -157,8 +162,19 @@ @dimen/grid_spacing_outer - + + diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml index ce186f1b1..4088d10f9 100644 --- a/app/src/main/res/values/themes.xml +++ b/app/src/main/res/values/themes.xml @@ -58,6 +58,7 @@ @style/TextAppearance.Widget.Menu @style/ThemeOverlay.Kotatsu.AlertDialog @style/ThemeOverlay.Kotatsu.BottomSheetDialog + @style/ThemeOverlay.Kotatsu.SideSheetDialog @style/TextAppearance.Kotatsu.Button ?attr/borderlessButtonStyle 0.32 @@ -70,7 +71,7 @@ @style/Widget.Material3.TextInputLayout.OutlinedBox @style/Widget.Material3.Toolbar @style/Widget.Kotatsu.AppBarLayout - @style/Widget.Kotatsu.BottomNavigationView.ColoredIndicators + @style/Widget.Kotatsu.BottomNavigationView @style/Widget.Kotatsu.Tabs @style/Widget.Kotatsu.CardView.Filled @style/Widget.Kotatsu.RecyclerView @@ -84,6 +85,7 @@ @style/Widget.Kotatsu.BottomSheet.DragHandle @style/Widget.Kotatsu.Spinner.DropDown @style/Widget.Kotatsu.DotIndicator + @style/Widget.Kotatsu.BadgeView @style/TextAppearance.Kotatsu.Menu diff --git a/app/src/main/res/xml/pref_appearance.xml b/app/src/main/res/xml/pref_appearance.xml index 27d3808e3..c49cbc17a 100644 --- a/app/src/main/res/xml/pref_appearance.xml +++ b/app/src/main/res/xml/pref_appearance.xml @@ -51,6 +51,13 @@ android:title="@string/show_reading_indicators" app:useSimpleSummaryProvider="true" /> + + diff --git a/app/src/main/res/xml/pref_backup_periodic.xml b/app/src/main/res/xml/pref_backup_periodic.xml index 06529b803..d55555267 100644 --- a/app/src/main/res/xml/pref_backup_periodic.xml +++ b/app/src/main/res/xml/pref_backup_periodic.xml @@ -47,7 +47,32 @@ android:key="backup_periodic_last" android:persistent="false" android:selectable="false" - app:allowDividerAbove="true" app:isPreferenceVisible="false" /> + + + + + + + + diff --git a/app/src/main/res/xml/pref_proxy.xml b/app/src/main/res/xml/pref_proxy.xml index 38391805e..a69d1cbec 100644 --- a/app/src/main/res/xml/pref_proxy.xml +++ b/app/src/main/res/xml/pref_proxy.xml @@ -40,7 +40,7 @@ diff --git a/app/src/main/res/xml/pref_reader.xml b/app/src/main/res/xml/pref_reader.xml index 0c7386ea1..b341c9217 100644 --- a/app/src/main/res/xml/pref_reader.xml +++ b/app/src/main/res/xml/pref_reader.xml @@ -23,6 +23,12 @@ app:allowDividerAbove="true" app:useSimpleSummaryProvider="true" /> + + - - + + + android:title="@string/reader_actions" /> + android:dependency="reader_bar" + android:key="reader_bar_transparent" + android:title="@string/reader_info_bar_transparent" /> x + android:title="@string/show_pages_numbers" /> + xmlns:app="http://schemas.android.com/apk/res-auto" + xmlns:tools="http://schemas.android.com/tools"> + + + app:isPreferenceVisible="false" + tools:isPreferenceVisible="true" /> diff --git a/app/src/main/res/xml/pref_sources.xml b/app/src/main/res/xml/pref_sources.xml index 687502d82..4179eed05 100644 --- a/app/src/main/res/xml/pref_sources.xml +++ b/app/src/main/res/xml/pref_sources.xml @@ -20,11 +20,17 @@ android:persistent="false" android:title="@string/manage_sources" /> + + + android:title="@string/sources_catalog" /> + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/xml/pref_user_data.xml b/app/src/main/res/xml/pref_user_data.xml index 5a606dd2b..529a902e3 100644 --- a/app/src/main/res/xml/pref_user_data.xml +++ b/app/src/main/res/xml/pref_user_data.xml @@ -54,59 +54,10 @@ - - - - - - - - - - - - - - - - - - - - - + diff --git a/app/src/nightly/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt b/app/src/nightly/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt new file mode 100644 index 000000000..c01174ef1 --- /dev/null +++ b/app/src/nightly/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.ui + +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleService + +abstract class BaseService : LifecycleService() { + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(ContextCompat.getContextForLanguage(newBase)) + } +} diff --git a/app/src/nightly/res/drawable/ic_launcher_foreground.xml b/app/src/nightly/res/drawable/ic_launcher_foreground.xml deleted file mode 100644 index 510acf9f4..000000000 --- a/app/src/nightly/res/drawable/ic_launcher_foreground.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml index d8a3b79e5..e010b2fe6 100644 --- a/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher.xml @@ -1,6 +1,6 @@ - - + + diff --git a/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher_round.xml index d8a3b79e5..e010b2fe6 100644 --- a/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/nightly/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -1,6 +1,6 @@ - - + + diff --git a/app/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.webp b/app/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..831de867a Binary files /dev/null and b/app/src/nightly/res/mipmap-hdpi/ic_launcher_foreground.webp differ diff --git a/app/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.webp b/app/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..b2fd3074b Binary files /dev/null and b/app/src/nightly/res/mipmap-mdpi/ic_launcher_foreground.webp differ diff --git a/app/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.webp b/app/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..c15adc8fb Binary files /dev/null and b/app/src/nightly/res/mipmap-xhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.webp b/app/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..475ee5d4b Binary files /dev/null and b/app/src/nightly/res/mipmap-xxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.webp b/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.webp new file mode 100644 index 000000000..77c82cd10 Binary files /dev/null and b/app/src/nightly/res/mipmap-xxxhdpi/ic_launcher_foreground.webp differ diff --git a/app/src/release/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt b/app/src/release/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt new file mode 100644 index 000000000..c01174ef1 --- /dev/null +++ b/app/src/release/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt @@ -0,0 +1,12 @@ +package org.koitharu.kotatsu.core.ui + +import android.content.Context +import androidx.core.content.ContextCompat +import androidx.lifecycle.LifecycleService + +abstract class BaseService : LifecycleService() { + + override fun attachBaseContext(newBase: Context) { + super.attachBaseContext(ContextCompat.getContextForLanguage(newBase)) + } +} diff --git a/app/src/test/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt b/app/src/test/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt index b4d2667ca..e0119a8c9 100644 --- a/app/src/test/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt +++ b/app/src/test/kotlin/org/koitharu/kotatsu/core/backup/JsonSerializerTest.kt @@ -7,6 +7,7 @@ import org.koitharu.kotatsu.core.db.entity.TagEntity import org.koitharu.kotatsu.favourites.data.FavouriteCategoryEntity import org.koitharu.kotatsu.favourites.data.FavouriteEntity import org.koitharu.kotatsu.history.data.HistoryEntity +import org.koitharu.kotatsu.parsers.model.ContentRating import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaState import org.koitharu.kotatsu.parsers.model.SortOrder @@ -33,15 +34,16 @@ class JsonSerializerTest { val entity = MangaEntity( id = 231, title = "Lorem Ipsum", - altTitle = "Lorem Ispum 2", + altTitles = "Lorem Ispum 2", url = "erw", publicUrl = "hthth", rating = 0.78f, isNsfw = true, + contentRating = ContentRating.ADULT.name, coverUrl = "5345", largeCoverUrl = null, state = MangaState.FINISHED.name, - author = "RERE", + authors = "RERE", source = MangaParserSource.DUMMY.name, ) val json = JsonSerializer(entity).toJson() diff --git a/build.gradle b/build.gradle index 83ac2313f..1f9ffe611 100644 --- a/build.gradle +++ b/build.gradle @@ -1,27 +1,7 @@ -buildscript { - repositories { - google() - mavenCentral() - } - dependencies { - // https://github.com/gradle/gradle/issues/16958 - classpath "${libs.plugins.android.get()}" - classpath "${libs.plugins.kotlin.get()}" - classpath "${libs.plugins.hilt.get()}" - classpath "${libs.plugins.ksp.get()}" - } -} - -allprojects { - repositories { - google() - mavenCentral() - maven { - url 'https://jitpack.io' - } - } -} - -tasks.register('clean', Delete) { - delete rootProject.layout.buildDirectory +plugins { + alias(libs.plugins.android.application) apply false + alias(libs.plugins.kotlin) apply false + alias(libs.plugins.hilt) apply false + alias(libs.plugins.ksp) apply false + alias(libs.plugins.room) apply false } diff --git a/gradle.properties b/gradle.properties index c628dc13b..682e3e860 100644 --- a/gradle.properties +++ b/gradle.properties @@ -18,4 +18,4 @@ kotlin.code.style=official org.gradle.jvmargs=-Xmx1536M -Dkotlin.daemon.jvm.options\="-Xmx1536M" android.enableR8.fullMode=true android.nonFinalResIds=false -kapt.use.k2=true +kapt.use.k2=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 2ed5058a7..6586770e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,49 +1,49 @@ [versions] acra = "5.12.0" -activity = "1.9.3" +activity = "1.10.1" adapterdelegates = "4.3.2" appcompat = "1.7.0" avifDecoder = "1.1.1.14d8e3c4" biometric = "1.2.0-alpha05" -coil = "3.0.4" -collections = "1.4.5" +coil = "3.1.0" +collections = "1.5.0" #noinspection GradleDependency - 2.5.3 cause crashes conscrypt = "2.5.2" -constraintlayout = "2.2.0" +constraintlayout = "2.2.1" coreKtx = "1.15.0" -coroutines = "1.9.0" -desugar = "2.1.3" +coroutines = "1.10.1" +desugar = "2.1.5" diskLruCache = "1.4" -fragment = "1.8.5" -gradle = "8.7.2" +fragment = "1.8.6" +gradle = "8.8.2" guava = "33.3.1-android" -hilt = "2.52" -hiltWork = "1.2.0" -json = "20240303" +dagger = "2.55" +hilt = "1.2.0" +json = "20250107" junit = "4.13.2" junitKtx = "1.2.1" -kotlin = "2.0.21" -kspPlugin = "2.0.21-1.0.27" +kotlin = "2.1.10" +ksp = "2.1.10-1.0.29" leakcanary = "3.0-alpha-8" lifecycle = "2.8.7" markwon = "4.6.2" -material = "1.12.0" +material = "1.13.0-alpha11" moshi = "1.15.2" okhttp = "4.12.0" -okio = "3.9.1" -parsers = "1.6" +okio = "3.10.2" +parsers = "5420290564" preference = "1.2.1" -recyclerview = "1.3.2" +recyclerview = "1.4.0" room = "2.6.1" -rules = "1.6.1" -runner = "1.6.2" ssiv = "ba48c29803" swiperefreshlayout = "1.1.0" +testRules = "1.6.1" +testRunner = "1.6.2" transition = "1.5.1" viewpager2 = "1.1.0" -webkit = "1.12.1" +webkit = "1.13.0" workRuntime = "2.10.0" -workinspector = "5778dd1747" +workinspector = "1.2" [libraries] acra-dialog = { module = "ch.acra:acra-dialog", version.ref = "acra" } @@ -57,8 +57,8 @@ androidx-collection = { module = "androidx.collection:collection-ktx", version.r androidx-constraintlayout = { module = "androidx.constraintlayout:constraintlayout", version.ref = "constraintlayout" } androidx-core = { module = "androidx.core:core-ktx", version.ref = "coreKtx" } androidx-fragment = { module = "androidx.fragment:fragment-ktx", version.ref = "fragment" } -androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hiltWork" } -androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hiltWork" } +androidx-hilt-compiler = { module = "androidx.hilt:hilt-compiler", version.ref = "hilt" } +androidx-hilt-work = { module = "androidx.hilt:hilt-work", version.ref = "hilt" } androidx-junit = { module = "androidx.test.ext:junit-ktx", version.ref = "junitKtx" } androidx-lifecycle-common-java8 = { module = "androidx.lifecycle:lifecycle-common-java8", version.ref = "lifecycle" } androidx-preference = { module = "androidx.preference:preference-ktx", version.ref = "preference" } @@ -67,10 +67,10 @@ androidx-room-compiler = { module = "androidx.room:room-compiler", version.ref = androidx-room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } androidx-room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } androidx-room-testing = { module = "androidx.room:room-testing", version.ref = "room" } -androidx-rules = { module = "androidx.test:rules", version.ref = "rules" } -androidx-runner = { module = "androidx.test:runner", version.ref = "runner" } +androidx-rules = { module = "androidx.test:rules", version.ref = "testRules" } +androidx-runner = { module = "androidx.test:runner", version.ref = "testRunner" } androidx-swiperefreshlayout = { module = "androidx.swiperefreshlayout:swiperefreshlayout", version.ref = "swiperefreshlayout" } -androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "rules" } +androidx-test-core = { module = "androidx.test:core-ktx", version.ref = "testRules" } androidx-transition = { module = "androidx.transition:transition-ktx", version.ref = "transition" } androidx-viewpager2 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } androidx-webkit = { module = "androidx.webkit:webkit", version.ref = "webkit" } @@ -84,10 +84,10 @@ conscrypt-android = { module = "org.conscrypt:conscrypt-android", version.ref = desugar_jdk_libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" } disk-lru-cache = { module = "com.github.solkin:disk-lru-cache", version.ref = "diskLruCache" } guava = { module = "com.google.guava:guava", version.ref = "guava" } -hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } -hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "hilt" } -hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "hilt" } -hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } +hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "dagger" } +hilt-android-compiler = { module = "com.google.dagger:hilt-android-compiler", version.ref = "dagger" } +hilt-android-testing = { module = "com.google.dagger:hilt-android-testing", version.ref = "dagger" } +hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "dagger" } json = { module = "org.json:json", version.ref = "json" } junit = { module = "junit:junit", version.ref = "junit" } kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib", version.ref = "kotlin" } @@ -109,7 +109,8 @@ ssiv = { module = "com.github.KotatsuApp:subsampling-scale-image-view", version. workinspector = { module = "com.github.Koitharu:WorkInspector", version.ref = "workinspector" } [plugins] -android = { id = "com.android.tools.build:gradle", version.ref = "gradle" } -hilt = { id = "com.google.dagger:hilt-android-gradle-plugin", version.ref = "hilt" } -kotlin = { id = "org.jetbrains.kotlin:kotlin-gradle-plugin", version.ref = "kotlin" } -ksp = { id = "com.google.devtools.ksp:symbol-processing-gradle-plugin", version.ref = "kspPlugin" } +android-application = { id = "com.android.application", version.ref = "gradle" } +kotlin = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } +hilt = { id = "com.google.dagger.hilt.android", version.ref = "dagger" } +room = { id = "androidx.room", version.ref = "room" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index 0e345a501..ef77a4518 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ #Wed Apr 03 08:23:55 EEST 2024 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionSha256Sum=31c55713e40233a8303827ceb42ca48a47267a0ad4bab9177123121e71524c26 -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-bin.zip +distributionSha256Sum=7a00d51fb93147819aab76024feece20b6b84e420694101f276be952e08bef03 +distributionUrl=https\://services.gradle.org/distributions/gradle-8.12-bin.zip zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists diff --git a/metadata/en-US/full_description.txt b/metadata/en-US/full_description.txt index 8bfaeac1e..c2e26b43c 100644 --- a/metadata/en-US/full_description.txt +++ b/metadata/en-US/full_description.txt @@ -4,15 +4,16 @@ Kotatsu is a free and open source manga reader for Android. **Main Features:** -- Online manga catalogues -- Search manga by name and genres -- Reading history and bookmarks -- Favourites organized by user-defined categories -- Downloading manga and reading it offline. Third-party CBZ archives also supported -- Tablet-optimized Material You UI -- Standard and Webtoon-optimized reader -- Notifications about new chapters with updates feed -- Integration with manga tracking services: Shikimori, AniList, MyAnimeList -- Password/fingerprint protect access to the app -- History and favourites synchronization across devices +- Online manga catalogs (with 1100+ manga sources) +- Search manga by name, genres, and more filters +- Favorites organized by user-defined categories +- Reading history, bookmarks, and incognito mode support +- Download manga and read it offline. Third-party CBZ archives are also supported +- Clean and convenient Material You UI, optimized for phones, tablets, and desktop +- Standard and Webtoon-optimized customizable reader, gesture support on reading interface +- Notifications about new chapters with updates feed, manga recommendations (with filters) +- Integration with manga tracking services: Shikimori, AniList, MyAnimeList, Kitsu +- Password / fingerprint-protected access to the app +- Automatically sync app data with other devices on the same account +- Support for older devices running Android 5+ diff --git a/metadata/en-US/images/phoneScreenshots/1.png b/metadata/en-US/images/phoneScreenshots/1.png index 663ae7765..bd263b9b4 100644 Binary files a/metadata/en-US/images/phoneScreenshots/1.png and b/metadata/en-US/images/phoneScreenshots/1.png differ diff --git a/metadata/en-US/images/phoneScreenshots/2.png b/metadata/en-US/images/phoneScreenshots/2.png index 67b5af97e..2516adc1e 100644 Binary files a/metadata/en-US/images/phoneScreenshots/2.png and b/metadata/en-US/images/phoneScreenshots/2.png differ diff --git a/metadata/en-US/images/phoneScreenshots/3.png b/metadata/en-US/images/phoneScreenshots/3.png index e4a3b15aa..f61d866c8 100644 Binary files a/metadata/en-US/images/phoneScreenshots/3.png and b/metadata/en-US/images/phoneScreenshots/3.png differ diff --git a/metadata/en-US/images/phoneScreenshots/4.png b/metadata/en-US/images/phoneScreenshots/4.png index dbd37ee9e..196971294 100644 Binary files a/metadata/en-US/images/phoneScreenshots/4.png and b/metadata/en-US/images/phoneScreenshots/4.png differ diff --git a/metadata/en-US/images/phoneScreenshots/5.png b/metadata/en-US/images/phoneScreenshots/5.png index 48a5e8aae..238cc69b4 100644 Binary files a/metadata/en-US/images/phoneScreenshots/5.png and b/metadata/en-US/images/phoneScreenshots/5.png differ diff --git a/metadata/en-US/images/phoneScreenshots/6.png b/metadata/en-US/images/phoneScreenshots/6.png index 6aa6ae26e..aeaca6c4a 100644 Binary files a/metadata/en-US/images/phoneScreenshots/6.png and b/metadata/en-US/images/phoneScreenshots/6.png differ diff --git a/metadata/en-US/images/tenInchScreenshots/1.png b/metadata/en-US/images/tenInchScreenshots/1.png index 923710845..68f728bea 100644 Binary files a/metadata/en-US/images/tenInchScreenshots/1.png and b/metadata/en-US/images/tenInchScreenshots/1.png differ diff --git a/metadata/en-US/images/tenInchScreenshots/2.png b/metadata/en-US/images/tenInchScreenshots/2.png index 7f339eb61..0bfb06d15 100644 Binary files a/metadata/en-US/images/tenInchScreenshots/2.png and b/metadata/en-US/images/tenInchScreenshots/2.png differ diff --git a/metadata/ru/full_description.txt b/metadata/ru/full_description.txt index 45b8af227..91b8b49ea 100644 --- a/metadata/ru/full_description.txt +++ b/metadata/ru/full_description.txt @@ -4,15 +4,16 @@ Kotatsu - приложения для чтения манги с открыты **Основные возможности:** -- Онлайн каталоги с мангой -- Поиск манги по имени и жанрам -- История чтения и закладки -- Избранное с пользовательскими категориями -- Возможность сохранять мангу и читать её офлайн. Поддержка сторонних комиксов в формате CBZ -- Интерфейс также оптимизирован для планшетов -- Поддержка манхвы (Webtoon) -- Уведомления о новых главах и лента обновлений -- Интеграция с Shikimori, AniList и MyAnimeList -- Защита доступа в приложение паролем/отпечатком пальца -- Синхронизация истории и избранного между устройствами +- Онлайн-каталоги манги (более 1100 источников) +- Поиск манги по названию, жанрам и другим фильтрам +- Избранное с настраиваемыми категориям +- История чтения, закладки и поддержка режима Инкогнито +- Загрузка манги с последующим чтением в автономном режиме. Также поддерживаются сторонние архивы CBZ +- Простой и удобный Material You интерфейс, оптимизированный как для телефонов, так и для планшетов и ПК +- Читалка с обычным и Webtoon режимами, поддержка жестов при чтении +- Уведомления о новых главах и лента обновлений, рекомендации манги (с фильтрами) +- Интеграция со службами отслеживания манги: Shikimori, AniList, MyAnimeList, Kitsu +- Защита доступа к приложению паролем или отпечатком пальца +- Автоматическая синхронизация данных приложения с другими устройствами в рамках учетной записи +- Поддержка старых устройств на базе Android 5+ diff --git a/metadata/ru/images/phoneScreenshots/1.png b/metadata/ru/images/phoneScreenshots/1.png index 869731f1f..45193a676 100644 Binary files a/metadata/ru/images/phoneScreenshots/1.png and b/metadata/ru/images/phoneScreenshots/1.png differ diff --git a/metadata/ru/images/phoneScreenshots/2.png b/metadata/ru/images/phoneScreenshots/2.png index fae0adaa2..3eb92de6e 100644 Binary files a/metadata/ru/images/phoneScreenshots/2.png and b/metadata/ru/images/phoneScreenshots/2.png differ diff --git a/metadata/ru/images/phoneScreenshots/3.png b/metadata/ru/images/phoneScreenshots/3.png index f7de7b7f5..cd7b440c2 100644 Binary files a/metadata/ru/images/phoneScreenshots/3.png and b/metadata/ru/images/phoneScreenshots/3.png differ diff --git a/metadata/ru/images/phoneScreenshots/4.png b/metadata/ru/images/phoneScreenshots/4.png index 0cdccaabd..90f5a2850 100644 Binary files a/metadata/ru/images/phoneScreenshots/4.png and b/metadata/ru/images/phoneScreenshots/4.png differ diff --git a/metadata/ru/images/phoneScreenshots/5.png b/metadata/ru/images/phoneScreenshots/5.png index 782af0b54..0977b9044 100644 Binary files a/metadata/ru/images/phoneScreenshots/5.png and b/metadata/ru/images/phoneScreenshots/5.png differ diff --git a/metadata/ru/images/phoneScreenshots/6.png b/metadata/ru/images/phoneScreenshots/6.png index 1703c6a74..ddb02c07f 100644 Binary files a/metadata/ru/images/phoneScreenshots/6.png and b/metadata/ru/images/phoneScreenshots/6.png differ diff --git a/metadata/ru/images/tenInchScreenshots/1.png b/metadata/ru/images/tenInchScreenshots/1.png index e4f98429d..883f389ad 100644 Binary files a/metadata/ru/images/tenInchScreenshots/1.png and b/metadata/ru/images/tenInchScreenshots/1.png differ diff --git a/metadata/ru/images/tenInchScreenshots/2.png b/metadata/ru/images/tenInchScreenshots/2.png index 3865e7cac..3fa65a734 100644 Binary files a/metadata/ru/images/tenInchScreenshots/2.png and b/metadata/ru/images/tenInchScreenshots/2.png differ diff --git a/metadata/vi/full_description.txt b/metadata/vi/full_description.txt new file mode 100644 index 000000000..9647b4909 --- /dev/null +++ b/metadata/vi/full_description.txt @@ -0,0 +1,20 @@ +Kotatsu là một ứng dụng đọc truyện mã nguồn mở miễn phí dành cho các thiết bị Android + + +Các tính năng chính: + + +- Hỗ trợ 100% tiếng Việt, được phát triển và đóng góp bởi cộng đồng người Việt +- Có nhiều nguồn đọc Online đa dạng (với hơn 1100 nguồn khác nhau), được cập nhật liên tục +- Tìm kiếm manga với nhiều bộ lọc khác nhau (tên, thể loại,...) +- Lưu lại manga vào lịch sử khi đọc / đánh dấu trang +- Mục yêu thích được sắp xếp theo thứ tự do người dùng quản lí +- Tải và đọc manga ngoại tuyến (không cần mạng). Các tệp lưu trữ manga định dạng CBZ cũng được hỗ trợ! +- Giao diện rõ ràng, đơn giản, được tối ưu hóa cho cả điện thoại & máy tính bảng +- Hỗ trợ nhiều chế độ đọc khác nhau: Webtoon; Trái qua phải; Phải qua trái;... +- Nhận các thông báo khi manga xuất hiện chương mới tại mục Feed của ứng dụng, nhận gợi ý manga (với bộ lọc tùy chọn) +- Tích hợp các trang giúp theo dõi manga: Shikimori, AniList, MyAnimeList, Kitsu +- Bảo vệ ứng dụng bằng mật khẩu cá nhân / Sử dụng sinh trắc học (vân tay) +- Tạo tài khoản giúp đồng bộ lịch sử và mục yêu thích của bạn trên nhiều thiết bị khác nhau +- Hỗ trợ cho các thiết bị cũ có cấu hình yếu (từ Android 5 trở lên) + diff --git a/metadata/vi/images/phoneScreenshots/1.png b/metadata/vi/images/phoneScreenshots/1.png new file mode 100644 index 000000000..29d700b42 Binary files /dev/null and b/metadata/vi/images/phoneScreenshots/1.png differ diff --git a/metadata/vi/images/phoneScreenshots/2.png b/metadata/vi/images/phoneScreenshots/2.png new file mode 100644 index 000000000..f3c00fda2 Binary files /dev/null and b/metadata/vi/images/phoneScreenshots/2.png differ diff --git a/metadata/vi/images/phoneScreenshots/3.png b/metadata/vi/images/phoneScreenshots/3.png new file mode 100644 index 000000000..e409eee6f Binary files /dev/null and b/metadata/vi/images/phoneScreenshots/3.png differ diff --git a/metadata/vi/images/phoneScreenshots/4.png b/metadata/vi/images/phoneScreenshots/4.png new file mode 100644 index 000000000..e1c3121cd Binary files /dev/null and b/metadata/vi/images/phoneScreenshots/4.png differ diff --git a/metadata/vi/images/phoneScreenshots/5.png b/metadata/vi/images/phoneScreenshots/5.png new file mode 100644 index 000000000..632059905 Binary files /dev/null and b/metadata/vi/images/phoneScreenshots/5.png differ diff --git a/metadata/vi/images/phoneScreenshots/6.png b/metadata/vi/images/phoneScreenshots/6.png new file mode 100644 index 000000000..02abb2d6a Binary files /dev/null and b/metadata/vi/images/phoneScreenshots/6.png differ diff --git a/metadata/vi/images/tenInchScreenshots/1.png b/metadata/vi/images/tenInchScreenshots/1.png new file mode 100644 index 000000000..665a80aae Binary files /dev/null and b/metadata/vi/images/tenInchScreenshots/1.png differ diff --git a/metadata/vi/images/tenInchScreenshots/2.png b/metadata/vi/images/tenInchScreenshots/2.png new file mode 100644 index 000000000..0f8be95df Binary files /dev/null and b/metadata/vi/images/tenInchScreenshots/2.png differ diff --git a/metadata/vi/short_description.txt b/metadata/vi/short_description.txt new file mode 100644 index 000000000..2c632b394 --- /dev/null +++ b/metadata/vi/short_description.txt @@ -0,0 +1 @@ +Ứng dụng đọc truyện miễn phí với nhiều nguồn Online đa dạng \ No newline at end of file diff --git a/settings.gradle b/settings.gradle index 2fba1a29b..c67f6b910 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,2 +1,32 @@ +pluginManagement { + repositories { + google { + content { + includeGroupByRegex("com\\.android.*") + includeGroupByRegex("com\\.google.*") + includeGroupByRegex("androidx.*") + } + } + mavenCentral() + gradlePluginPortal() + maven { + url 'https://jitpack.io' + content { + includeGroupByRegex("com\\.github.*") + } + } + } +} +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + repositories { + google() + mavenCentral() + maven { + url 'https://jitpack.io' + } + } +} + include ':app' -rootProject.name = "Kotatsu" \ No newline at end of file +rootProject.name = "Kotatsu"