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.
+
+
+
-[](https://github.com/KotatsuApp/kotatsu-parsers)   [](https://hosted.weblate.org/engage/kotatsu/) [](https://t.me/kotatsuapp) [](https://discord.gg/NNJ5RgVBC5) [](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.**
+
+   [](https://github.com/KotatsuApp/kotatsu-parsers) [](https://hosted.weblate.org/engage/kotatsu/) [](https://discord.gg/NNJ5RgVBC5) [](https://t.me/kotatsuapp) [](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
+
-|  |  |  |
-|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
-|  |  |  |
+
-|  |  |
-|-----------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------|
+
+
+
+
### Localization
-[
](https://hosted.weblate.org/engage/kotatsu/)
+
+
+
-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.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines**
### License
[](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