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 a1d638eaf..a32f8514b 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 = 702
- versionName = '7.7.10'
+ versionCode = 1000
+ versionName = '8.0-a1'
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..6453ac7fe
--- /dev/null
+++ b/app/src/debug/kotlin/org/koitharu/kotatsu/core/ui/BaseService.kt
@@ -0,0 +1,15 @@
+package org.koitharu.kotatsu.core.ui
+
+import androidx.lifecycle.LifecycleService
+import leakcanary.AppWatcher
+
+abstract class BaseService : LifecycleService() {
+
+ 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 @@
+
{
- 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
}
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..22f39d347 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,7 +1,5 @@
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
@@ -13,8 +11,7 @@ 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
@@ -22,7 +19,6 @@ import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityAlternativesBinding
-import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
@@ -30,8 +26,6 @@ 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
@@ -65,7 +59,7 @@ class AlternativesActivity : BaseActivity(),
viewModel.content.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()
}
}
@@ -82,16 +76,9 @@ class AlternativesActivity : BaseActivity(),
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)
}
}
@@ -114,10 +101,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..42ec2ac97 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
@@ -13,7 +13,7 @@ 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.ui.BaseViewModel
@@ -40,7 +40,7 @@ class AlternativesViewModel @Inject constructor(
private val settings: AppSettings,
) : BaseViewModel() {
- val manga = savedStateHandle.require(MangaIntent.KEY_MANGA).manga
+ val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga
val onMigrated = MutableEventFlow()
val content = MutableStateFlow>(listOf(LoadingState))
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..e286d641e 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,13 @@ 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.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
-import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import javax.inject.Inject
@@ -122,7 +121,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/bookmarks/ui/AllBookmarksActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/bookmarks/ui/AllBookmarksActivity.kt
index 85591a372..7aad6f547 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,7 +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
@@ -46,9 +44,4 @@ class AllBookmarksActivity :
right = insets.right,
)
}
-
- companion object {
-
- fun newIntent(context: Context) = Intent(context, AllBookmarksActivity::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..3bea2b4d3 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
@@ -20,6 +20,8 @@ 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
@@ -30,7 +32,6 @@ 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.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
@@ -115,26 +115,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
@@ -208,16 +208,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/browser/BrowserActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt
index 159550134..4f9f3e85b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/browser/BrowserActivity.kt
@@ -1,27 +1,23 @@
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 com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
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 javax.inject.Inject
import com.google.android.material.R as materialR
@@ -42,11 +38,10 @@ class BrowserActivity : BaseActivity(), BrowserCallback
setDisplayHomeAsUpEnabled(true)
setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material)
}
- val mangaSource = MangaSource(intent?.getStringExtra(EXTRA_SOURCE))
+ 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)
@@ -59,7 +54,7 @@ class BrowserActivity : BaseActivity(), BrowserCallback
finishAfterTransition()
} else {
onTitleChanged(
- intent?.getStringExtra(EXTRA_TITLE) ?: getString(R.string.loading_),
+ intent?.getStringExtra(AppRouter.KEY_TITLE) ?: getString(R.string.loading_),
url,
)
viewBinding.webView.loadUrl(url)
@@ -80,14 +75,8 @@ 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
}
@@ -136,17 +125,4 @@ class BrowserActivity : BaseActivity(), BrowserCallback
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/cloudflare/CaptchaNotifier.kt b/app/src/main/kotlin/org/koitharu/kotatsu/browser/cloudflare/CaptchaNotifier.kt
index 48d900c10..c889e11dd 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,7 @@ 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.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.parsers.model.MangaSource
@@ -38,7 +39,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..5d43a277b 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,15 +1,12 @@
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
@@ -19,19 +16,17 @@ 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.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.parsers.network.CloudFlareHelper
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -62,12 +57,11 @@ class CloudFlareActivity : BaseActivity(), CloudFlareCal
return
}
cfClient = CloudFlareClient(cookieJar, this, url)
- viewBinding.webView.configureForParser(intent?.getStringExtra(ARG_UA))
+ 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)
@@ -140,7 +134,7 @@ 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))
}
@@ -182,38 +176,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/core/BaseApp.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/BaseApp.kt
index 8825d4cbe..47833c2ee 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
@@ -89,10 +90,8 @@ open class BaseApp : Application(), Configuration.Provider {
}
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/backup/BackupRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/backup/BackupRepository.kt
index a8c45f0ca..3c3d33ffc 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,11 +1,13 @@
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
@@ -128,9 +130,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 +148,7 @@ class BackupRepository @Inject constructor(
db.getHistoryDao().upsert(history)
}
}
+ outProgress?.emit(Progress(progress = index, total = list.size))
}
return result
}
@@ -159,9 +164,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 +182,7 @@ class BackupRepository @Inject constructor(
db.getFavouritesDao().upsert(favourite)
}
}
+ outProgress?.emit(Progress(progress = index, total = list.size))
}
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/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/db/MangaDatabase.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/db/MangaDatabase.kt
index f87bc5b0a..116d08ce2 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,6 +38,7 @@ 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.Migration2To3
import org.koitharu.kotatsu.core.db.migrations.Migration3To4
@@ -64,14 +67,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 = 24
@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 +107,8 @@ abstract class MangaDatabase : RoomDatabase() {
abstract fun getStatsDao(): StatsDao
abstract fun getLocalMangaIndexDao(): LocalMangaIndexDao
+
+ abstract fun getChaptersDao(): ChaptersDao
}
fun getDatabaseMigrations(context: Context): Array = arrayOf(
@@ -129,6 +134,7 @@ fun getDatabaseMigrations(context: Context): Array = arrayOf(
Migration20To21(),
Migration21To22(),
Migration22To23(),
+ Migration23To24(),
Migration24To23(),
)
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..d03827c99
--- /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 name: 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..fd9d83f28 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
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.db.entity
import org.koitharu.kotatsu.core.model.MangaSource
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
@@ -21,7 +22,7 @@ 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( // TODO
id = this.id,
title = this.title,
altTitle = this.altTitle,
@@ -35,12 +36,27 @@ fun MangaEntity.toManga(tags: Set) = Manga(
author = this.author,
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,
+ name = name,
+ 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(
@@ -67,6 +83,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,
+ name = chapter.name,
+ 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 {
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/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..5d85d162b 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
@@ -85,8 +86,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/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/nav/AppRouter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt
new file mode 100644
index 000000000..b31b7708e
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/nav/AppRouter.kt
@@ -0,0 +1,624 @@
+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.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.provider.Settings
+import android.view.View
+import androidx.annotation.CheckResult
+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 org.koitharu.kotatsu.BuildConfig
+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.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.ReaderMode
+import org.koitharu.kotatsu.core.ui.dialog.ErrorDetailsDialog
+import org.koitharu.kotatsu.core.util.ext.findActivity
+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.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.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
+
+class AppRouter private constructor(
+ private val activity: FragmentActivity?,
+ private val fragment: Fragment?,
+) {
+
+ constructor(activity: FragmentActivity) : this(activity, null)
+
+ constructor(fragment: Fragment) : this(null, fragment)
+
+ /** Activities **/
+
+ fun openList(source: MangaSource, filter: MangaListFilter?) {
+ startActivity(listIntent(contextOrNull() ?: return, source, filter))
+ }
+
+ fun openList(tag: MangaTag) = openList(tag.source, MangaListFilter(tags = setOf(tag)))
+
+ fun openSearch(query: String) {
+ startActivity(
+ Intent(contextOrNull() ?: return, SearchActivity::class.java)
+ .putExtra(KEY_QUERY, query),
+ )
+ }
+
+ fun openSearch(source: MangaSource, query: String) = openList(source, MangaListFilter(query = query))
+
+ fun openDetails(manga: Manga) {
+ startActivity(detailsIntent(contextOrNull() ?: return, manga))
+ }
+
+ fun openDetails(mangaId: Long) {
+ startActivity(detailsIntent(contextOrNull() ?: return, mangaId))
+ }
+
+ 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 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()
+ }
+
+ /** 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 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?): Intent =
+ Intent(context, MangaListActivity::class.java)
+ .setAction(ACTION_MANGA_EXPLORE)
+ .putExtra(KEY_SOURCE, source.name)
+ .apply {
+ if (!filter.isNullOrEmpty()) {
+ putExtra(KEY_FILTER, ParcelableMangaListFilter(filter))
+ }
+ }
+
+ 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.get(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)
+ }
+
+ 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_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_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 fun Class.fragmentTag() = name // TODO
+
+ private inline fun fragmentTag() = F::class.java.fragmentTag()
+ }
+}
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/os/AppShortcutManager.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/os/AppShortcutManager.kt
index e406d8e55..ab44b1819 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
@@ -155,9 +155,10 @@ class AppShortcutManager @Inject constructor(
.setIcon(icon)
.setLongLived(true)
.setIntent(
- ReaderActivity.IntentBuilder(context)
+ ReaderIntent.Builder(context)
.mangaId(manga.id)
- .build(),
+ .build()
+ .intent,
)
.build()
}
@@ -181,7 +182,7 @@ class AppShortcutManager @Inject constructor(
.setLongLabel(title)
.setIcon(icon)
.setLongLived(true)
- .setIntent(MangaListActivity.newIntent(context, source, null))
+ .setIntent(AppRouter.listIntent(context, source, 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/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/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/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/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/prefs/AppSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/prefs/AppSettings.kt
index 25b220cae..d5a413fb2 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
@@ -141,6 +141,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 +304,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 +372,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 +498,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)
@@ -621,6 +636,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 +680,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,7 +731,10 @@ 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"
// keys for non-persistent preferences
const val KEY_APP_VERSION = "app_version"
@@ -729,6 +748,10 @@ 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"
// 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/ui/BaseActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BaseActivity.kt
index f9c89b131..0f03503c5 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
@@ -21,6 +21,7 @@ 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
@@ -159,7 +160,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 +179,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/BasePreferenceFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/BasePreferenceFragment.kt
index db75d16e7..32338846e 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,8 +1,6 @@
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
@@ -14,10 +12,8 @@ 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
@@ -89,14 +85,6 @@ abstract class BasePreferenceFragment(@StringRes private val titleId: Int) :
(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/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/dialog/ErrorDetailsDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/dialog/ErrorDetailsDialog.kt
index 690e2c769..5cfbd992a 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
@@ -10,14 +10,14 @@ 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 com.google.android.material.dialog.MaterialAlertDialogBuilder
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
+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.databinding.DialogErrorDetailsBinding
class ErrorDetailsDialog : AlertDialogFragment() {
@@ -27,7 +27,7 @@ class ErrorDetailsDialog : AlertDialogFragment() {
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 {
@@ -41,7 +41,7 @@ class ErrorDetailsDialog : AlertDialogFragment() {
text = context.getString(
R.string.manga_error_description_pattern,
exception.message?.htmlEncode().orEmpty(),
- arguments?.getString(ARG_URL),
+ arguments?.getString(AppRouter.KEY_URL) ?: exception.getCauseUrl(),
).parseAsHtml(HtmlCompat.FROM_HTML_MODE_LEGACY)
}
}
@@ -71,16 +71,4 @@ class ErrorDetailsDialog : AlertDialogFragment() {
ClipData.newPlainText(getString(R.string.error), exception.stackTraceToString()),
)
}
-
- 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/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/FastScroller.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/ui/list/fastscroll/FastScroller.kt
index 0c31b0a1a..795999419 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
@@ -162,7 +162,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")
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 ea731925c..285a6ce0a 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
@@ -1,17 +1,18 @@
package org.koitharu.kotatsu.core.ui.model
-import android.content.res.Resources
+import android.content.Context
+import android.text.format.DateUtils
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.util.ext.toMillis
import java.time.LocalDate
-import java.time.format.DateTimeFormatter
sealed class DateTimeAgo {
- abstract fun format(resources: Resources): String
+ abstract fun format(context: Context): String
object JustNow : DateTimeAgo() {
- override fun format(resources: Resources): String {
- return resources.getString(R.string.just_now)
+ override fun format(context: Context): String {
+ return context.getString(R.string.just_now)
}
override fun toString() = "just_now"
@@ -20,24 +21,32 @@ sealed class DateTimeAgo {
}
data class MinutesAgo(val minutes: Int) : DateTimeAgo() {
- override fun format(resources: Resources): String {
- return resources.getQuantityString(R.plurals.minutes_ago, minutes, minutes)
+ override fun format(context: Context): String {
+ return context.resources.getQuantityString(
+ R.plurals.minutes_ago,
+ minutes,
+ minutes,
+ )
}
override fun toString() = "minutes_ago_$minutes"
}
data class HoursAgo(val hours: Int) : DateTimeAgo() {
- override fun format(resources: Resources): String {
- return resources.getQuantityString(R.plurals.hours_ago, hours, hours)
+ override fun format(context: Context): String {
+ return context.resources.getQuantityString(
+ R.plurals.hours_ago,
+ hours,
+ hours,
+ )
}
override fun toString() = "hours_ago_$hours"
}
object Today : DateTimeAgo() {
- override fun format(resources: Resources): String {
- return resources.getString(R.string.today)
+ override fun format(context: Context): String {
+ return context.getString(R.string.today)
}
override fun toString() = "today"
@@ -46,8 +55,8 @@ sealed class DateTimeAgo {
}
object Yesterday : DateTimeAgo() {
- override fun format(resources: Resources): String {
- return resources.getString(R.string.yesterday)
+ override fun format(context: Context): String {
+ return context.getString(R.string.yesterday)
}
override fun toString() = "yesterday"
@@ -56,44 +65,46 @@ sealed class DateTimeAgo {
}
data class DaysAgo(val days: Int) : DateTimeAgo() {
- override fun format(resources: Resources): String {
- return resources.getQuantityString(R.plurals.days_ago, days, days)
+ override fun format(context: Context): String {
+ return context.resources.getQuantityString(R.plurals.days_ago, days, days)
}
override fun toString() = "days_ago_$days"
}
data class MonthsAgo(val months: Int) : DateTimeAgo() {
- override fun format(resources: Resources): String {
+ override fun format(context: Context): String {
return if (months == 0) {
- resources.getString(R.string.this_month)
+ context.getString(R.string.this_month)
} else {
- resources.getQuantityString(R.plurals.months_ago, months, months)
+ context.resources.getQuantityString(
+ R.plurals.months_ago,
+ months,
+ months,
+ )
}
}
}
data class Absolute(private val date: LocalDate) : DateTimeAgo() {
- override fun format(resources: Resources): String {
+ override fun format(context: Context): String {
return if (date == EPOCH_DATE) {
- resources.getString(R.string.unknown)
+ context.getString(R.string.unknown)
} else {
- date.format(formatter)
+ DateUtils.formatDateTime(context, date.toMillis(), DateUtils.FORMAT_SHOW_DATE)
}
}
override fun toString() = "abs_${date.toEpochDay()}"
- companion object {
- // TODO: Use Java 9's LocalDate.EPOCH.
- private val EPOCH_DATE = LocalDate.of(1970, 1, 1)
- private val formatter = DateTimeFormatter.ofPattern("d MMMM")
+ private companion object {
+ val EPOCH_DATE: LocalDate = LocalDate.of(1970, 1, 1)
}
}
object LongAgo : DateTimeAgo() {
- override fun format(resources: Resources): String {
- return resources.getString(R.string.long_ago)
+ override fun format(context: Context): String {
+ return context.getString(R.string.long_ago)
}
override fun toString() = "long_ago"
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..2805778ed 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,7 +3,6 @@ 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
@@ -34,7 +33,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 +48,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 +60,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) {
@@ -170,12 +171,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 +180,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 +205,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..ad8f1fd5a
--- /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 = LinearLayout.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
index 133b66071..33f955630 100644
--- 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
@@ -28,6 +28,7 @@ import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import com.google.android.material.R as materialR
+@Deprecated("")
class ProgressButton @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null,
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/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/ext/Android.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Android.kt
index 7f1a649e0..01c1f0cba 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,7 +5,6 @@ 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.ComponentName
import android.content.Context
@@ -23,19 +22,18 @@ 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
@@ -86,12 +84,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,
@@ -171,7 +171,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 +185,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 +265,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() {
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..3a892c297 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
@@ -110,3 +110,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/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/Date.kt b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Date.kt
index e854483d2..4f4b0a66d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Date.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/ext/Date.kt
@@ -35,6 +35,7 @@ fun calculateTimeAgo(instant: Instant, showMonths: Boolean = false): DateTimeAgo
}
}
+@Suppress("KotlinConstantConditions")
fun Long.toInstantOrNull() = if (this == 0L) null else Instant.ofEpochMilli(this)
fun Resources.formatDurationShort(millis: Long): String? {
@@ -50,3 +51,5 @@ fun Resources.formatDurationShort(millis: Long): String? {
else -> getString(R.string.seconds_short, seconds)
}
}
+
+fun LocalDate.toMillis(): Long = atStartOfDay(ZoneId.systemDefault()).toInstant().toEpochMilli()
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..3a89f87f8 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
@@ -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,6 @@ 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)
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..dc166a96a 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,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.os.Bundle
import androidx.core.view.MenuProvider
-import androidx.fragment.app.DialogFragment
import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentManager
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.coroutineScope
@@ -18,36 +16,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
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/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..e2e915619 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
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.core.util.ext
import android.content.SharedPreferences
import androidx.preference.ListPreference
+import androidx.preference.MultiSelectListPreference
fun ListPreference.setDefaultValueCompat(defaultValue: String) {
if (value == null) {
@@ -9,6 +10,10 @@ fun ListPreference.setDefaultValueCompat(defaultValue: String) {
}
}
+fun MultiSelectListPreference.setDefaultValueCompat(defaultValue: Set) {
+ setDefaultValue(defaultValue)
+}
+
fun > SharedPreferences.getEnumValue(key: String, enumClass: Class): E? {
val stringValue = getString(key, null) ?: return null
return enumClass.enumConstants?.find {
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..3e518cde0 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
@@ -27,7 +27,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 +39,6 @@ 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.net.ConnectException
import java.net.NoRouteToHostException
import java.net.SocketException
@@ -223,9 +221,3 @@ 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
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..503c76e73
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/core/util/progress/Progress.kt
@@ -0,0 +1,47 @@
+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
+
+ 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()
+}
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..7f7c96509 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
@@ -14,8 +14,8 @@ 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.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
@@ -43,7 +43,14 @@ class DetailsLoadUseCase @Inject constructor(
operator fun invoke(intent: MangaIntent): 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 +58,9 @@ class DetailsLoadUseCase @Inject constructor(
} else {
null
}
- send(MangaDetails(manga, null, null, false))
try {
val details = getDetails(manga)
+ launch { mangaDataRepository.updateChapters(details) }
launch { updateTracker(details) }
send(
MangaDetails(
@@ -122,4 +129,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..5d213fc82 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,15 +1,9 @@
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.Gravity
import android.view.MenuItem
import android.view.View
import android.view.ViewGroup.MarginLayoutParams
@@ -18,8 +12,6 @@ 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.isGone
import androidx.core.view.isVisible
@@ -37,9 +29,11 @@ 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
@@ -55,70 +49,60 @@ 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.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.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.enqueueWith
-import org.koitharu.kotatsu.core.util.ext.getThemeColor
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.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.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.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
@@ -140,30 +124,24 @@ class DetailsActivity :
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))
+ infoBinding = LayoutDetailsTableBinding.bind(viewBinding.root)
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)
+ viewBinding.chipFavorite.setOnClickListener(this)
+ infoBinding.textViewLocal.setOnClickListener(this)
+ infoBinding.textViewAuthor.setOnClickListener(this)
+ infoBinding.textViewSource.setOnClickListener(this)
viewBinding.imageViewCover.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)
@@ -172,16 +150,19 @@ class DetailsActivity :
TitleScrollCoordinator(viewBinding.textViewTitle).attach(viewBinding.scrollView)
viewBinding.containerBottomSheet?.let { sheet ->
onBackPressedDispatcher.addCallback(BottomSheetCollapseCallback(sheet))
+ BottomSheetBehavior.from(sheet)
+ .addBottomSheetCallback(DetailsBottomSheetCallback(viewBinding.swipeRefreshLayout))
}
TitleExpandListener(viewBinding.textViewTitle).attach()
+ val appRouter = router
viewModel.mangaDetails.filterNotNull().observe(this, ::onMangaUpdated)
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 +171,24 @@ 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.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,63 +202,32 @@ 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.chip_author -> {
+ R.id.textView_author -> {
val manga = viewModel.manga.value ?: return
- startActivity(
- MangaListActivity.newIntent(
- context = v.context,
- source = manga.source,
- filter = MangaListFilter(query = manga.author),
- ),
- )
+ router.openSearch(manga.source, manga.author ?: return)
}
- R.id.chip_source -> {
+ R.id.textView_source -> {
val manga = viewModel.manga.value ?: return
- startActivity(
- MangaListActivity.newIntent(
- context = v.context,
- source = manga.source,
- filter = null,
- ),
- )
+ router.openList(manga.source, null)
}
- R.id.chip_size -> {
+ R.id.textView_local -> {
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 = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } ?: return,
+ source = manga.source,
+ anchor = v,
)
}
@@ -293,12 +243,12 @@ 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)
}
}
}
@@ -306,7 +256,7 @@ class DetailsActivity :
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))))
+ router.openList(tag)
}
override fun onContextClick(v: View): Boolean = onLongClick(v)
@@ -344,9 +294,7 @@ class DetailsActivity :
}
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()
}
@@ -378,7 +326,7 @@ class DetailsActivity :
}
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 +335,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 +361,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 +378,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 +386,59 @@ class DetailsActivity :
}
private fun onMangaUpdated(details: MangaDetails) {
+ val manga = details.toManga()
+ loadCover(manga)
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)
+ 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
- }
-
- 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)
+ textViewSource.textAndVisible = manga.source.getTitle(this@DetailsActivity)
+ textViewSourceLabel.isVisible = textViewSource.isVisible == true
}
-
- 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)
}
+ bindTags(manga)
+ title = manga.title
+ invalidateOptionsMenu()
}
private fun onMangaRemoved(manga: Manga) {
@@ -527,69 +468,32 @@ class DetailsActivity :
}
}
- 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)
+ .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
+ textViewProgress.textAndVisible = if (info.percent <= 0f) {
+ null
+ } else {
+ getString(R.string.percent_string_pattern, (info.percent * 100f).toInt().toString())
}
- menu.show()
+ 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 openReader(isIncognitoMode: Boolean) {
@@ -598,8 +502,8 @@ class DetailsActivity :
Snackbar.make(viewBinding.scrollView, R.string.chapter_is_missing, Snackbar.LENGTH_SHORT)
.show()
} else {
- startActivity(
- ReaderActivity.IntentBuilder(this)
+ router.openReader(
+ ReaderIntent.Builder(this)
.manga(manga)
.branch(viewModel.selectedBranchValue)
.incognito(isIncognitoMode)
@@ -642,6 +546,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 +575,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..d351a9b15
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/DetailsBottomSheetCallback.kt
@@ -0,0 +1,16 @@
+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,
+) : BottomSheetBehavior.BottomSheetCallback() {
+
+ override fun onStateChanged(bottomSheet: View, newState: Int) {
+ swipeRefreshLayout.isEnabled = newState == BottomSheetBehavior.STATE_COLLAPSED
+ }
+
+ override fun onSlide(bottomSheet: View, slideOffset: Float) = Unit
+}
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..ceedcfdd8 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
@@ -14,16 +14,11 @@ 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.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
class DetailsMenuProvider(
private val activity: FragmentActivity,
@@ -49,23 +44,21 @@ 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)
- }
+ val shareHelper = ShareHelper(activity)
+ if (manga.isLocal) {
+ shareHelper.shareCbz(listOf(manga.url.toUri().toFile()))
+ } else {
+ shareHelper.shareMangaLink(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))
+ .setMessage(activity.getString(R.string.text_delete_local_manga, manga.title))
.setPositiveButton(R.string.delete) { _, _ ->
viewModel.deleteLocal()
}
@@ -74,52 +67,38 @@ class DetailsMenuProvider(
}
R.id.action_save -> {
- DownloadDialogFragment.show(activity.supportFragmentManager, listOfNotNull(viewModel.manga.value))
+ activity.router.showDownloadDialog(manga, snackbarHost)
}
R.id.action_browser -> {
- viewModel.manga.value?.let {
- activity.startActivity(BrowserActivity.newIntent(activity, it.publicUrl, it.source, it.title))
- }
+ activity.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))
- }
+ activity.router.openDetails(manga)
}
R.id.action_related -> {
- viewModel.manga.value?.let {
- activity.startActivity(SearchActivity.newIntent(activity, it.title))
- }
+ activity.router.openSearch(manga.title)
}
R.id.action_alternatives -> {
- viewModel.manga.value?.let {
- activity.startActivity(AlternativesActivity.newIntent(activity, it))
- }
+ activity.router.openAlternatives(manga)
}
R.id.action_stats -> {
- viewModel.manga.value?.let {
- MangaStatsSheet.show(activity.supportFragmentManager, it)
- }
+ activity.router.showStatisticSheet(manga)
}
R.id.action_scrobbling -> {
- viewModel.manga.value?.let {
- ScrobblingSelectorSheet.show(activity.supportFragmentManager, it, null)
- }
+ activity.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..25ba313ce 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 }
@@ -144,6 +146,7 @@ class DetailsViewModel @Inject constructor(
mangaListMapper.toListModelList(
manga = relatedMangaUseCase(it).orEmpty(),
mode = ListMode.GRID,
+ flags = 0,
)
} else {
emptyList()
@@ -170,14 +173,6 @@ 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
@@ -222,14 +217,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))
@@ -240,14 +227,15 @@ class DetailsViewModel @Inject constructor(
private fun doLoad() = launchLoadingJob(Dispatchers.Default) {
detailsLoadUseCase.invoke(intent)
.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/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..43570c7f7 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
@@ -6,10 +6,11 @@ import android.view.View
import android.view.ViewGroup
import androidx.appcompat.view.ActionMode
import androidx.core.view.isVisible
-import androidx.fragment.app.FragmentManager
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
@@ -26,9 +27,9 @@ 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.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
@@ -49,11 +50,14 @@ 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
@@ -87,7 +91,9 @@ class ChaptersPagesSheet : BaseAdaptiveSheet(), Actio
}
val binding = viewBinding ?: return
val isActionModeStarted = actionModeDelegate?.isActionModeStarted == true
- binding.toolbar.menuView?.isVisible = newState != STATE_COLLAPSED && !isActionModeStarted
+ binding.toolbar.menuView?.isVisible = newState == STATE_EXPANDED && !isActionModeStarted
+ binding.splitButtonRead.isVisible = newState != STATE_EXPANDED && !isActionModeStarted
+ && viewModel is DetailsViewModel
}
override fun onActionModeStarted(mode: ActionMode) {
@@ -138,22 +144,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..e4ac4dfaf 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/ChaptersPagesViewModel.kt
@@ -19,14 +19,17 @@ import kotlinx.coroutines.flow.update
import kotlinx.coroutines.plus
import okio.FileNotFoundException
import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository
+import org.koitharu.kotatsu.core.model.toChipModel
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsStateFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
+import org.koitharu.kotatsu.core.util.LocaleStringComparator
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.core.util.ext.requireValue
+import org.koitharu.kotatsu.core.util.ext.sortedWithSafe
import org.koitharu.kotatsu.details.data.MangaDetails
import org.koitharu.kotatsu.details.domain.DetailsInteractor
import org.koitharu.kotatsu.details.ui.DetailsActivity
@@ -36,6 +39,7 @@ import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.download.ui.worker.DownloadTask
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.history.data.HistoryRepository
+import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
import org.koitharu.kotatsu.parsers.model.Manga
@@ -57,7 +61,6 @@ abstract class ChaptersPagesViewModel(
val readingState = MutableStateFlow(null)
val onActionDone = MutableEventFlow()
- val onSelectChapter = MutableEventFlow()
val onDownloadStarted = MutableEventFlow()
val onMangaRemoved = MutableEventFlow()
@@ -119,6 +122,18 @@ abstract class ChaptersPagesViewModel(
(if (reversed) list.asReversed() else list).filterSearch(query)
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, emptyList())
+ val quickFilter = combine(
+ mangaDetails,
+ selectedBranch,
+ ) { details, branch ->
+ val branches = details?.chapters?.keys?.sortedWithSafe(LocaleStringComparator()).orEmpty()
+ if (branches.size > 1) {
+ branches.map { ListFilterOption.Branch(it).toChipModel(it == branch) }
+ } else {
+ emptyList()
+ }
+ }
+
init {
launchJob(Dispatchers.Default) {
localStorageChanges
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt
index b554b645a..5c201c9b5 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/bookmarks/BookmarksFragment.kt
@@ -18,13 +18,15 @@ import org.koitharu.kotatsu.bookmarks.domain.Bookmark
import org.koitharu.kotatsu.bookmarks.ui.BookmarksSelectionDecoration
import org.koitharu.kotatsu.bookmarks.ui.adapter.BookmarksAdapter
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
+import org.koitharu.kotatsu.core.nav.ReaderIntent
+import org.koitharu.kotatsu.core.nav.dismissParentDialog
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
-import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
@@ -34,7 +36,6 @@ import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.list.ui.GridSpanResolver
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
-import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import javax.inject.Inject
@@ -124,21 +125,21 @@ class BookmarksFragment : BaseFragment(),
if (listener != null && listener.onBookmarkSelected(item)) {
dismissParentDialog()
} else {
- val intent = IntentBuilder(view.context)
+ val intent = ReaderIntent.Builder(view.context)
.manga(activityViewModel.getMangaOrNull() ?: return)
.bookmark(item)
.incognito(true)
.build()
- startActivity(intent)
+ router.openReader(intent)
}
}
override fun onItemLongClick(item: Bookmark, view: View): Boolean {
- return selectionController?.onItemLongClick(view, item.pageId) ?: false
+ return selectionController?.onItemLongClick(view, item.pageId) == true
}
override fun onItemContextClick(item: Bookmark, view: View): Boolean {
- return selectionController?.onItemContextClick(view, item.pageId) ?: false
+ return selectionController?.onItemContextClick(view, item.pageId) == true
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt
index 0c82e7c83..aefa857e8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/chapters/ChaptersFragment.kt
@@ -5,38 +5,38 @@ import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.core.graphics.Insets
-import androidx.core.view.ancestors
+import androidx.core.view.isGone
import androidx.core.view.isVisible
import androidx.recyclerview.widget.GridLayoutManager
import androidx.recyclerview.widget.LinearLayoutManager
-import androidx.viewpager2.widget.ViewPager2
+import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.flow.firstOrNull
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.map
-import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.ReaderIntent
+import org.koitharu.kotatsu.core.nav.dismissParentDialog
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
+import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
-import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
-import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.FragmentChaptersBinding
import org.koitharu.kotatsu.details.ui.adapter.ChaptersAdapter
import org.koitharu.kotatsu.details.ui.adapter.ChaptersSelectionDecoration
import org.koitharu.kotatsu.details.ui.model.ChapterListItem
import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesViewModel
import org.koitharu.kotatsu.details.ui.withVolumeHeaders
+import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
-import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState
import javax.inject.Inject
@@ -45,7 +45,7 @@ import kotlin.math.roundToInt
@AndroidEntryPoint
class ChaptersFragment :
BaseFragment(),
- OnListItemClickListener {
+ OnListItemClickListener, ChipsView.OnChipClickListener {
private val viewModel by ChaptersPagesViewModel.ActivityVMLazy(this)
@@ -86,15 +86,16 @@ class ChaptersFragment :
adapter = chaptersAdapter
ChapterGridSpanHelper.attach(this)
}
+ binding.chipsFilter.onChipClickListener = this
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
viewModel.chapters
.map { it.withVolumeHeaders(requireContext()) }
.flowOn(Dispatchers.Default)
.observe(viewLifecycleOwner, this::onChaptersChanged)
+ viewModel.quickFilter.observe(viewLifecycleOwner, this::onFilterChanged)
viewModel.isChaptersEmpty.observe(viewLifecycleOwner) {
binding.textViewHolder.isVisible = it
}
- viewModel.onSelectChapter.observeEvent(viewLifecycleOwner, ::onSelectChapter)
}
override fun onDestroyView() {
@@ -111,8 +112,8 @@ class ChaptersFragment :
if (listener != null && listener.onChapterSelected(item.chapter)) {
dismissParentDialog()
} else {
- startActivity(
- IntentBuilder(view.context)
+ router.openReader(
+ ReaderIntent.Builder(view.context)
.manga(viewModel.getMangaOrNull() ?: return)
.state(ReaderState(item.chapter.id, 0, 0))
.build(),
@@ -121,11 +122,16 @@ class ChaptersFragment :
}
override fun onItemLongClick(item: ChapterListItem, view: View): Boolean {
- return selectionController?.onItemLongClick(view, item.chapter.id) ?: false
+ return selectionController?.onItemLongClick(view, item.chapter.id) == true
}
override fun onItemContextClick(item: ChapterListItem, view: View): Boolean {
- return selectionController?.onItemContextClick(view, item.chapter.id) ?: false
+ return selectionController?.onItemContextClick(view, item.chapter.id) == true
+ }
+
+ override fun onChipClick(chip: Chip, data: Any?) {
+ if (data !is ListFilterOption.Branch) return
+ viewModel.setSelectedBranch(data.titleText)
}
override fun onWindowInsetsChanged(insets: Insets) = Unit
@@ -148,22 +154,10 @@ class ChaptersFragment :
}
}
- private suspend fun onSelectChapter(chapterId: Long) {
- if (!isResumed) {
- view?.ancestors?.firstNotNullOfOrNull { it as? ViewPager2 }?.setCurrentItem(0, true)
- }
- val position = withContext(Dispatchers.Default) {
- val predicate: (ListModel) -> Boolean = { x -> x is ChapterListItem && x.chapter.id == chapterId }
- val items = chaptersAdapter?.observeItems()?.firstOrNull { it.any(predicate) }
- items?.indexOfFirst(predicate) ?: -1
- }
- if (position >= 0) {
- selectionController?.startSelection(chapterId)
- val lm = (viewBinding?.recyclerViewChapters?.layoutManager as? LinearLayoutManager)
- if (lm != null) {
- val offset = resources.getDimensionPixelOffset(R.dimen.chapter_list_item_height)
- lm.scrollToPositionWithOffset(position, offset)
- }
+ private fun onFilterChanged(list: List) {
+ viewBinding?.chipsFilter?.run {
+ setChips(list)
+ isGone = list.isEmpty()
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt
index e63304849..6599dd281 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/MangaPageFetcher.kt
@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.details.ui.pager.pages
-import android.webkit.MimeTypeMap
import androidx.core.net.toUri
import coil3.ImageLoader
import coil3.decode.DataSource
@@ -21,8 +20,10 @@ import okio.Path.Companion.toOkioPath
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaRepository
+import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.fetch
import org.koitharu.kotatsu.core.util.ext.isNetworkUri
+import org.koitharu.kotatsu.core.util.ext.toMimeTypeOrNull
import org.koitharu.kotatsu.local.data.PagesCache
import org.koitharu.kotatsu.parsers.model.MangaPage
import org.koitharu.kotatsu.parsers.util.mimeType
@@ -47,7 +48,7 @@ class MangaPageFetcher(
pagesCache.get(pageUrl)?.let { file ->
return SourceFetchResult(
source = ImageSource(file.toOkioPath(), options.fileSystem),
- mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(file.extension),
+ mimeType = MimeTypes.getMimeTypeFromExtension(file.name)?.toString(),
dataSource = DataSource.DISK,
)
}
@@ -67,13 +68,13 @@ class MangaPageFetcher(
if (!response.isSuccessful) {
throw HttpException(response.toNetworkResponse())
}
- val mimeType = response.mimeType
+ val mimeType = response.mimeType?.toMimeTypeOrNull()
val file = response.requireBody().use {
pagesCache.put(pageUrl, it.source(), mimeType)
}
SourceFetchResult(
source = ImageSource(file.toOkioPath(), FileSystem.SYSTEM),
- mimeType = mimeType,
+ mimeType = mimeType?.toString(),
dataSource = DataSource.NETWORK,
)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt
index eadeec114..73c709830 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/pager/pages/PagesFragment.kt
@@ -22,6 +22,9 @@ import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flowOn
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
+import org.koitharu.kotatsu.core.nav.ReaderIntent
+import org.koitharu.kotatsu.core.nav.dismissParentDialog
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.list.BoundsScrollListener
@@ -29,7 +32,6 @@ import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.util.PagerNestedScrollHelper
import org.koitharu.kotatsu.core.util.RecyclerViewScrollCallback
-import org.koitharu.kotatsu.core.util.ext.dismissParentDialog
import org.koitharu.kotatsu.core.util.ext.findAppCompatDelegate
import org.koitharu.kotatsu.core.util.ext.findParentCallback
import org.koitharu.kotatsu.core.util.ext.observe
@@ -42,7 +44,6 @@ import org.koitharu.kotatsu.list.ui.adapter.ListItemType
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
import org.koitharu.kotatsu.reader.ui.PageSaveHelper
-import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.reader.ui.ReaderNavigationCallback
import org.koitharu.kotatsu.reader.ui.ReaderState
import org.koitharu.kotatsu.reader.ui.pager.ReaderPage
@@ -151,8 +152,8 @@ class PagesFragment :
if (listener != null && listener.onPageSelected(item.page)) {
dismissParentDialog()
} else {
- startActivity(
- IntentBuilder(view.context)
+ router.openReader(
+ ReaderIntent.Builder(view.context)
.manga(parentViewModel.getMangaOrNull() ?: return)
.state(ReaderState(item.page.chapterId, item.page.index, 0))
.build(),
@@ -161,11 +162,11 @@ class PagesFragment :
}
override fun onItemLongClick(item: PageThumbnail, view: View): Boolean {
- return selectionController?.onItemLongClick(view, item.page.id) ?: false
+ return selectionController?.onItemLongClick(view, item.page.id) == true
}
override fun onItemContextClick(item: PageThumbnail, view: View): Boolean {
- return selectionController?.onItemContextClick(view, item.page.id) ?: false
+ return selectionController?.onItemContextClick(view, item.page.id) == true
}
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt
index 679f36693..1c77dc858 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedListViewModel.kt
@@ -13,7 +13,7 @@ import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
-import org.koitharu.kotatsu.core.parser.MangaIntent
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.util.ext.call
@@ -37,7 +37,7 @@ class RelatedListViewModel @Inject constructor(
downloadScheduler: DownloadWorker.Scheduler,
) : MangaListViewModel(settings, downloadScheduler) {
- private val seed = savedStateHandle.require(MangaIntent.KEY_MANGA).manga
+ private val seed = savedStateHandle.require(AppRouter.KEY_MANGA).manga
private val repository = mangaRepositoryFactory.create(seed.source)
private val mangaList = MutableStateFlow?>(null)
private val listError = MutableStateFlow(null)
@@ -52,7 +52,7 @@ class RelatedListViewModel @Inject constructor(
list.isNullOrEmpty() && error != null -> listOf(error.toErrorState(canRetry = true))
list == null -> listOf(LoadingState)
list.isEmpty() -> listOf(createEmptyState())
- else -> mangaListMapper.toListModelList(list, mode)
+ else -> mangaListMapper.toListModelList(list, mode, 0)
}
}.stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt
index fc8806f64..6803cbf10 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/related/RelatedMangaActivity.kt
@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.details.ui.related
-import android.content.Context
-import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
@@ -9,12 +7,9 @@ import androidx.fragment.app.commit
import com.google.android.material.appbar.AppBarLayout
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
-import org.koitharu.kotatsu.core.parser.MangaIntent
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
-import org.koitharu.kotatsu.parsers.model.Manga
@AndroidEntryPoint
class RelatedMangaActivity : BaseActivity(), AppBarOwner {
@@ -41,10 +36,4 @@ class RelatedMangaActivity : BaseActivity(), AppBarOwn
right = insets.right,
)
}
-
- companion object {
-
- fun newIntent(context: Context, seed: Manga) = Intent(context, RelatedMangaActivity::class.java)
- .putExtra(MangaIntent.KEY_MANGA, ParcelableManga(seed))
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt
index 6b629ec8d..1e1990a15 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoAD.kt
@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.details.ui.scrobbling
-import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
@@ -15,12 +15,12 @@ import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
fun scrobblingInfoAD(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
- fragmentManager: FragmentManager,
+ router: AppRouter,
) = adapterDelegateViewBinding(
{ layoutInflater, parent -> ItemScrobblingInfoBinding.inflate(layoutInflater, parent, false) },
) {
binding.root.setOnClickListener {
- ScrobblingInfoSheet.show(fragmentManager, bindingAdapterPosition)
+ router.showScrobblingInfoSheet(bindingAdapterPosition)
}
bind {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt
index 69b25e63e..c90797e18 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrobblingInfoSheet.kt
@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.details.ui.scrobbling
-import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.MenuItem
@@ -10,13 +9,14 @@ import android.widget.AdapterView
import android.widget.RatingBar
import android.widget.Toast
import androidx.appcompat.widget.PopupMenu
-import androidx.core.net.toUri
import androidx.core.text.method.LinkMovementMethodCompat
-import androidx.fragment.app.FragmentManager
import androidx.fragment.app.activityViewModels
import coil3.ImageLoader
+import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.AppRouter
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.defaultPlaceholders
import org.koitharu.kotatsu.core.util.ext.enqueueWith
@@ -25,14 +25,10 @@ import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.sanitize
-import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
-import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetScrobblingBinding
import org.koitharu.kotatsu.details.ui.DetailsViewModel
-import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo
import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingStatus
-import org.koitharu.kotatsu.scrobbling.common.ui.selector.ScrobblingSelectorSheet
import javax.inject.Inject
@AndroidEntryPoint
@@ -53,7 +49,7 @@ class ScrobblingInfoSheet :
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
- scrobblerIndex = requireArguments().getInt(ARG_INDEX, scrobblerIndex)
+ scrobblerIndex = requireArguments().getInt(AppRouter.KEY_INDEX, scrobblerIndex)
}
override fun onCreateViewBinding(inflater: LayoutInflater, container: ViewGroup?): SheetScrobblingBinding {
@@ -108,11 +104,11 @@ class ScrobblingInfoSheet :
override fun onClick(v: View) {
when (v.id) {
R.id.button_menu -> menu?.show()
- R.id.imageView_cover -> {
- val coverUrl = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return
- val options = scaleUpActivityOptionsOf(v)
- startActivity(ImageActivity.newIntent(v.context, coverUrl, null), options)
- }
+ R.id.imageView_cover -> router.openImage(
+ url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.coverUrl ?: return,
+ source = null,
+ anchor = v,
+ )
}
}
@@ -139,10 +135,13 @@ class ScrobblingInfoSheet :
when (item.itemId) {
R.id.action_browser -> {
val url = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.externalUrl ?: return false
- val intent = Intent(Intent.ACTION_VIEW, url.toUri())
- startActivity(
- Intent.createChooser(intent, getString(R.string.open_in_browser)),
- )
+ if (!router.openExternalBrowser(url, getString(R.string.open_in_browser))) {
+ Snackbar.make(
+ viewBinding?.textViewDescription ?: return false,
+ R.string.operation_not_supported,
+ Snackbar.LENGTH_SHORT,
+ ).show()
+ }
}
R.id.action_unregister -> {
@@ -153,20 +152,10 @@ class ScrobblingInfoSheet :
R.id.action_edit -> {
val manga = viewModel.manga.value ?: return false
val scrobblerService = viewModel.scrobblingInfo.value.getOrNull(scrobblerIndex)?.scrobbler
- ScrobblingSelectorSheet.show(parentFragmentManager, manga, scrobblerService)
+ router.showScrobblingSelectorSheet(manga, scrobblerService)
dismiss()
}
}
return true
}
-
- companion object {
-
- private const val TAG = "ScrobblingInfoBottomSheet"
- private const val ARG_INDEX = "index"
-
- fun show(fm: FragmentManager, index: Int) = ScrobblingInfoSheet().withArgs(1) {
- putInt(ARG_INDEX, index)
- }.show(fm, TAG)
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt
index fe205632a..f9ab7a6d0 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/details/ui/scrobbling/ScrollingInfoAdapter.kt
@@ -1,18 +1,18 @@
package org.koitharu.kotatsu.details.ui.scrobbling
-import androidx.fragment.app.FragmentManager
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.list.ui.model.ListModel
class ScrollingInfoAdapter(
lifecycleOwner: LifecycleOwner,
coil: ImageLoader,
- fragmentManager: FragmentManager,
+ router: AppRouter,
) : BaseListAdapter() {
init {
- delegatesManager.addDelegate(scrobblingInfoAD(lifecycleOwner, coil, fragmentManager))
+ delegatesManager.addDelegate(scrobblingInfoAD(lifecycleOwner, coil, router))
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt
index 76ce31f9a..fc2dccaf5 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogFragment.kt
@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.download.ui.dialog
-import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.Menu
@@ -9,17 +8,16 @@ import android.view.ViewGroup
import android.widget.Spinner
import androidx.appcompat.widget.PopupMenu
import androidx.core.view.isVisible
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentActivity
import androidx.fragment.app.FragmentManager
import androidx.fragment.app.FragmentResultListener
import androidx.fragment.app.setFragmentResult
import androidx.fragment.app.viewModels
+import androidx.lifecycle.LifecycleOwner
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.DownloadFormat
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.dialog.CommonAlertDialogs
@@ -30,13 +28,9 @@ import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.parentView
-import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.core.util.ext.showOrHide
-import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogDownloadBinding
-import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
-import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.mapToArray
import org.koitharu.kotatsu.settings.storage.DirectoryModel
@@ -75,6 +69,7 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie
binding.buttonConfirm.setOnClickListener(this)
binding.textViewMore.setOnClickListener(this)
+ binding.textViewTip.isVisible = viewModel.manga.size == 1
binding.textViewSummary.text = viewModel.manga.joinToStringWithLimit(binding.root.context, 120) { it.title }
viewModel.isLoading.observe(viewLifecycleOwner, this::onLoadingStateChanged)
@@ -324,7 +319,9 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie
}
}
- private class SnackbarResultListener(private val host: View) : FragmentResultListener {
+ private class SnackbarResultListener(
+ private val host: View,
+ ) : FragmentResultListener {
override fun onFragmentResult(requestKey: String, result: Bundle) {
val isStarted = result.getBoolean(ARG_STARTED, true)
@@ -336,8 +333,9 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie
(host.context.findActivity() as? BottomNavOwner)?.let {
snackbar.anchorView = it.bottomNav
}
- snackbar.setAction(R.string.details) {
- it.context.startActivity(Intent(it.context, DownloadsActivity::class.java))
+ val router = AppRouter.from(host)
+ if (router != null) {
+ snackbar.setAction(R.string.details) { router.openDownloads() }
}
snackbar.show()
}
@@ -345,28 +343,16 @@ class DownloadDialogFragment : AlertDialogFragment(), Vie
companion object {
- private const val TAG = "DownloadDialogFragment"
private const val RESULT_KEY = "DOWNLOAD_STARTED"
private const val ARG_STARTED = "started"
private const val KEY_CHECKED_OPTION = "checked_opt"
- const val ARG_MANGA = "manga"
- fun show(fm: FragmentManager, manga: Collection) = DownloadDialogFragment().withArgs(1) {
- putParcelableArray(ARG_MANGA, manga.mapToArray { ParcelableManga(it) })
- }.showDistinct(fm, TAG)
+ fun registerCallback(
+ fm: FragmentManager,
+ lifecycleOwner: LifecycleOwner,
+ snackbarHost: View
+ ) = fm.setFragmentResultListener(RESULT_KEY, lifecycleOwner, SnackbarResultListener(snackbarHost))
- fun registerCallback(activity: FragmentActivity, snackbarHost: View) =
- activity.supportFragmentManager.setFragmentResultListener(
- RESULT_KEY,
- activity,
- SnackbarResultListener(snackbarHost),
- )
-
- fun registerCallback(fragment: Fragment, snackbarHost: View) =
- fragment.childFragmentManager.setFragmentResultListener(
- RESULT_KEY,
- fragment.viewLifecycleOwner,
- SnackbarResultListener(snackbarHost),
- )
+ fun unregisterCallback(fm: FragmentManager) = fm.clearFragmentResultListener(RESULT_KEY)
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt
index 97a69844a..9095abb8d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/dialog/DownloadDialogViewModel.kt
@@ -13,6 +13,7 @@ import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.DownloadFormat
@@ -45,7 +46,7 @@ class DownloadDialogViewModel @Inject constructor(
private val settings: AppSettings,
) : BaseViewModel() {
- val manga = savedStateHandle.require>(DownloadDialogFragment.ARG_MANGA).map {
+ val manga = savedStateHandle.require>(AppRouter.KEY_MANGA).map {
it.manga
}
private val mangaDetails = suspendLazy {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt
index 0c823471e..3fdf55b60 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsActivity.kt
@@ -12,6 +12,7 @@ import androidx.core.view.updatePadding
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
@@ -20,7 +21,6 @@ import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityDownloadsBinding
-import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.ui.worker.DownloadWorker
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import javax.inject.Inject
@@ -84,7 +84,7 @@ class DownloadsActivity : BaseActivity(),
if (selectionController.onItemClick(item.id.mostSignificantBits)) {
return
}
- startActivity(DetailsActivity.newIntent(view.context, item.manga ?: return))
+ router.openDetails(item.manga ?: return)
}
override fun onItemLongClick(item: DownloadItemModel, view: View): Boolean {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt
index 128412c2f..933948a93 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsMenuProvider.kt
@@ -1,16 +1,16 @@
package org.koitharu.kotatsu.download.ui.list
-import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
+import androidx.fragment.app.FragmentActivity
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
-import org.koitharu.kotatsu.settings.SettingsActivity
class DownloadsMenuProvider(
- private val context: Context,
+ private val activity: FragmentActivity,
private val viewModel: DownloadsViewModel,
) : MenuProvider {
@@ -24,10 +24,7 @@ class DownloadsMenuProvider(
R.id.action_resume -> viewModel.resumeAll()
R.id.action_cancel_all -> confirmCancelAll()
R.id.action_remove_completed -> confirmRemoveCompleted()
- R.id.action_settings -> {
- context.startActivity(SettingsActivity.newDownloadsSettingsIntent(context))
- }
-
+ R.id.action_settings -> activity.router.openDownloadsSetting()
else -> return false
}
return true
@@ -41,7 +38,7 @@ class DownloadsMenuProvider(
}
private fun confirmCancelAll() {
- buildAlertDialog(context, isCentered = true) {
+ buildAlertDialog(activity, isCentered = true) {
setTitle(R.string.cancel_all)
setMessage(R.string.cancel_all_downloads_confirm)
setIcon(R.drawable.ic_cancel_multiple)
@@ -51,7 +48,7 @@ class DownloadsMenuProvider(
}
private fun confirmRemoveCompleted() {
- buildAlertDialog(context, isCentered = true) {
+ buildAlertDialog(activity, isCentered = true) {
setTitle(R.string.remove_completed)
setMessage(R.string.remove_completed_downloads_confirm)
setIcon(R.drawable.ic_clear_all)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt
index 44795ee5b..6a74eb16b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/list/DownloadsViewModel.kt
@@ -289,7 +289,7 @@ class DownloadsViewModel @Inject constructor(
}
return cacheMutex.withLock {
mangaCache.getOrElse(mangaId) {
- mangaDataRepository.findMangaById(mangaId)?.also {
+ mangaDataRepository.findMangaById(mangaId, withChapters = true)?.also {
mangaCache[mangaId] = it
} ?: return null
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt
index 016bb14f2..3cd30a1aa 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadNotificationFactory.kt
@@ -25,17 +25,16 @@ import kotlinx.coroutines.sync.withLock
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ErrorReporterReceiver
import org.koitharu.kotatsu.core.model.LocalMangaSource
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow
import org.koitharu.kotatsu.core.util.ext.isReportable
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
-import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.download.domain.DownloadState
import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.format
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
-import org.koitharu.kotatsu.search.ui.MangaListActivity
import java.util.UUID
import com.google.android.material.R as materialR
@@ -267,9 +266,9 @@ class DownloadNotificationFactory @AssistedInject constructor(
context,
manga.hashCode(),
if (manga != null) {
- DetailsActivity.newIntent(context, manga)
+ AppRouter.detailsIntent(context, manga)
} else {
- MangaListActivity.newIntent(context, LocalMangaSource, null)
+ AppRouter.listIntent(context, LocalMangaSource, null)
},
PendingIntent.FLAG_CANCEL_CURRENT,
false,
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt
index 7ec69616e..a418c0a7c 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadStartedObserver.kt
@@ -1,12 +1,11 @@
package org.koitharu.kotatsu.download.ui.worker
-import android.content.Intent
import android.view.View
import com.google.android.material.snackbar.Snackbar
import kotlinx.coroutines.flow.FlowCollector
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.util.ext.findActivity
-import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
class DownloadStartedObserver(
@@ -18,8 +17,9 @@ class DownloadStartedObserver(
(snackbarHost.context.findActivity() as? BottomNavOwner)?.let {
snackbar.anchorView = it.bottomNav
}
- snackbar.setAction(R.string.details) {
- it.context.startActivity(Intent(it.context, DownloadsActivity::class.java))
+ val router = AppRouter.from(snackbarHost)
+ if (router != null) {
+ snackbar.setAction(R.string.details) { router.openDownloads() }
}
snackbar.show()
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt
index 45d6c2438..5ec73a7f8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/download/ui/worker/DownloadWorker.kt
@@ -5,7 +5,6 @@ import android.app.NotificationManager
import android.content.Context
import android.content.pm.ServiceInfo
import android.os.Build
-import android.webkit.MimeTypeMap
import androidx.core.content.ContextCompat
import androidx.hilt.work.HiltWorker
import androidx.work.BackoffPolicy
@@ -25,6 +24,7 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
@@ -32,6 +32,7 @@ import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.channelFlow
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
+import kotlinx.coroutines.runInterruptible
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withContext
@@ -42,6 +43,7 @@ import okio.buffer
import okio.sink
import okio.use
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.image.BitmapDecoderCompat
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.network.MangaHttpClient
@@ -49,7 +51,9 @@ import org.koitharu.kotatsu.core.network.imageproxy.ImageProxyInterceptor
import org.koitharu.kotatsu.core.parser.MangaDataRepository
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.Throttler
+import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.awaitFinishedWorkInfosByTag
import org.koitharu.kotatsu.core.util.ext.awaitUpdateWork
import org.koitharu.kotatsu.core.util.ext.awaitWorkInfosByTag
@@ -61,6 +65,7 @@ import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
import org.koitharu.kotatsu.core.util.ext.getWorkInputData
import org.koitharu.kotatsu.core.util.ext.getWorkSpec
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
+import org.koitharu.kotatsu.core.util.ext.toMimeType
import org.koitharu.kotatsu.core.util.ext.withTicker
import org.koitharu.kotatsu.core.util.ext.writeAllCancellable
import org.koitharu.kotatsu.core.util.progress.RealtimeEtaEstimator
@@ -120,7 +125,7 @@ class DownloadWorker @AssistedInject constructor(
override suspend fun doWork(): Result {
setForeground(getForegroundInfo())
- val manga = mangaDataRepository.findMangaById(task.mangaId) ?: return Result.failure()
+ val manga = mangaDataRepository.findMangaById(task.mangaId, withChapters = true) ?: return Result.failure()
publishState(DownloadState(manga = manga, isIndeterminate = true).also { lastPublishedState = it })
val downloadedIds = getDoneChapters(manga)
return try {
@@ -132,7 +137,7 @@ class DownloadWorker @AssistedInject constructor(
downloadMangaImpl(manga, task, downloadedIds)
}
Result.success(currentState.toWorkData())
- } catch (e: CancellationException) {
+ } catch (_: CancellationException) {
withContext(NonCancellable) {
val notification = notificationFactory.create(currentState.copy(isStopped = true))
notificationManager.notify(id.hashCode(), notification)
@@ -201,7 +206,7 @@ class DownloadWorker @AssistedInject constructor(
val coverUrl = mangaDetails.largeCoverUrl.ifNullOrEmpty { mangaDetails.coverUrl }
if (!coverUrl.isNullOrEmpty()) {
downloadFile(coverUrl, destination, repo.source).let { file ->
- output.addCover(file, MimeTypeMap.getFileExtensionFromUrl(coverUrl))
+ output.addCover(file, getMediaType(coverUrl, file))
file.deleteAwait()
}
}
@@ -230,7 +235,7 @@ class DownloadWorker @AssistedInject constructor(
chapter = chapter,
file = file,
pageNumber = pageIndex,
- ext = MimeTypeMap.getFileExtensionFromUrl(url),
+ type = getMediaType(url, file),
)
if (file.extension == "tmp") {
file.deleteAwait()
@@ -354,6 +359,13 @@ class DownloadWorker @AssistedInject constructor(
}
}
+ private suspend fun getMediaType(url: String, file: File): MimeType? = runInterruptible(Dispatchers.IO) {
+ BitmapDecoderCompat.probeMimeType(file)?.let {
+ return@runInterruptible it
+ }
+ MimeTypes.getMimeTypeFromUrl(url)
+ }
+
private suspend fun downloadFile(
url: String,
destination: File,
@@ -364,18 +376,29 @@ class DownloadWorker @AssistedInject constructor(
return imageProxyInterceptor.interceptPageRequest(request, okHttp)
.ensureSuccess()
.use { response ->
- val file = File(destination, UUID.randomUUID().toString() + ".tmp")
+ var file: File? = null
try {
response.requireBody().use { body ->
+ file = File(
+ destination,
+ buildString {
+ append(UUID.randomUUID().toString())
+ MimeTypes.getExtension(body.contentType()?.toMimeType())?.let { ext ->
+ append('.')
+ append(ext)
+ }
+ append(".tmp")
+ },
+ )
file.sink(append = false).buffer().use {
it.writeAllCancellable(body.source())
}
}
} catch (e: CancellationException) {
- file.delete()
+ file?.delete()
throw e
}
- file
+ checkNotNull(file)
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt
index 04249df34..818e76400 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/data/MangaSourcesRepository.kt
@@ -14,6 +14,7 @@ import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.conflate
import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onStart
import org.koitharu.kotatsu.BuildConfig
@@ -61,13 +62,14 @@ class MangaSourcesRepository @Inject constructor(
suspend fun getEnabledSources(): List {
assimilateNewSources()
val order = settings.sourcesSortOrder
- return dao.findAllEnabled(order).toSources(settings.isNsfwContentDisabled, order).let { enabled ->
- val external = getExternalSources()
- val list = ArrayList(enabled.size + external.size)
- external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
- list.addAll(enabled)
- list
- }
+ return dao.findAll(!settings.isAllSourcesEnabled, order).toSources(settings.isNsfwContentDisabled, order)
+ .let { enabled ->
+ val external = getExternalSources()
+ val list = ArrayList(enabled.size + external.size)
+ external.mapTo(list) { MangaSourceInfo(it, isEnabled = true, isPinned = true) }
+ list.addAll(enabled)
+ list
+ }
}
suspend fun getPinnedSources(): Set {
@@ -85,6 +87,9 @@ class MangaSourcesRepository @Inject constructor(
suspend fun getDisabledSources(): Set {
assimilateNewSources()
+ if (settings.isAllSourcesEnabled) {
+ return emptySet()
+ }
val result = EnumSet.copyOf(allMangaSources)
val enabled = dao.findAllEnabledNames()
for (name in enabled) {
@@ -105,7 +110,7 @@ class MangaSourcesRepository @Inject constructor(
): List {
assimilateNewSources()
val entities = dao.findAll().toMutableList()
- if (isDisabledOnly) {
+ if (isDisabledOnly && !settings.isAllSourcesEnabled) {
entities.removeAll { it.isEnabled }
}
if (isNewOnly) {
@@ -141,7 +146,9 @@ class MangaSourcesRepository @Inject constructor(
fun observeEnabledSourcesCount(): Flow {
return combine(
observeIsNsfwDisabled(),
- dao.observeEnabled(SourcesSortOrder.MANUAL),
+ observeAllEnabled().flatMapLatest { isAllSourcesEnabled ->
+ dao.observeAll(!isAllSourcesEnabled, SourcesSortOrder.MANUAL)
+ },
) { skipNsfw, sources ->
sources.count {
it.source.toMangaSourceOrNull()?.let { s -> !skipNsfw || !s.isNsfw() } == true
@@ -152,7 +159,9 @@ class MangaSourcesRepository @Inject constructor(
fun observeAvailableSourcesCount(): Flow {
return combine(
observeIsNsfwDisabled(),
- dao.observeEnabled(SourcesSortOrder.MANUAL),
+ observeAllEnabled().flatMapLatest { isAllSourcesEnabled ->
+ dao.observeAll(!isAllSourcesEnabled, SourcesSortOrder.MANUAL)
+ },
) { skipNsfw, enabledSources ->
val enabled = enabledSources.mapToSet { it.source }
allMangaSources.count { x ->
@@ -163,9 +172,10 @@ class MangaSourcesRepository @Inject constructor(
fun observeEnabledSources(): Flow> = combine(
observeIsNsfwDisabled(),
+ observeAllEnabled(),
observeSortOrder(),
- ) { skipNsfw, order ->
- dao.observeEnabled(order).map {
+ ) { skipNsfw, allEnabled, order ->
+ dao.observeAll(!allEnabled, order).map {
it.toSources(skipNsfw, order)
}
}.flattenLatest()
@@ -249,10 +259,11 @@ class MangaSourcesRepository @Inject constructor(
return false
}
var maxSortKey = dao.getMaxSortKey()
+ val isAllEnabled = settings.isAllSourcesEnabled
val entities = new.map { x ->
MangaSourceEntity(
source = x.name,
- isEnabled = false,
+ isEnabled = isAllEnabled,
sortKey = ++maxSortKey,
addedIn = BuildConfig.VERSION_CODE,
lastUsedAt = 0,
@@ -355,6 +366,7 @@ class MangaSourcesRepository @Inject constructor(
skipNsfwSources: Boolean,
sortOrder: SourcesSortOrder?,
): MutableList {
+ val isAllEnabled = settings.isAllSourcesEnabled
val result = ArrayList(size)
for (entity in this) {
val source = entity.source.toMangaSourceOrNull() ?: continue
@@ -365,7 +377,7 @@ class MangaSourcesRepository @Inject constructor(
result.add(
MangaSourceInfo(
mangaSource = source,
- isEnabled = entity.isEnabled,
+ isEnabled = entity.isEnabled || isAllEnabled,
isPinned = entity.isPinned,
),
)
@@ -385,5 +397,9 @@ class MangaSourcesRepository @Inject constructor(
sourcesSortOrder
}
+ private fun observeAllEnabled() = settings.observeAsFlow(AppSettings.KEY_SOURCES_ENABLED_ALL) {
+ isAllSourcesEnabled
+ }
+
private fun String.toMangaSourceOrNull(): MangaParserSource? = MangaParserSource.entries.find { it.name == this }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
index de68d0922..34de65789 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreFragment.kt
@@ -21,9 +21,9 @@ import androidx.recyclerview.widget.RecyclerView
import coil3.ImageLoader
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.bookmarks.ui.AllBookmarksActivity
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.LocalMangaSource
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.dialog.BigButtonsAlertDialog
@@ -37,8 +37,6 @@ 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.databinding.FragmentExploreBinding
-import org.koitharu.kotatsu.details.ui.DetailsActivity
-import org.koitharu.kotatsu.download.ui.list.DownloadsActivity
import org.koitharu.kotatsu.explore.ui.adapter.ExploreAdapter
import org.koitharu.kotatsu.explore.ui.adapter.ExploreListEventListener
import org.koitharu.kotatsu.explore.ui.model.MangaSourceItem
@@ -46,10 +44,6 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListHeader
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaParserSource
-import org.koitharu.kotatsu.search.ui.MangaListActivity
-import org.koitharu.kotatsu.settings.SettingsActivity
-import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity
-import org.koitharu.kotatsu.suggestions.ui.SuggestionsActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -76,7 +70,7 @@ class ExploreFragment :
override fun onViewBindingCreated(binding: FragmentExploreBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
exploreAdapter = ExploreAdapter(coil, viewLifecycleOwner, this, this) { manga, view ->
- startActivity(DetailsActivity.newIntent(view.context, manga))
+ router.openDetails(manga)
}
sourceSelectionController = ListSelectionController(
appCompatDelegate = checkNotNull(findAppCompatDelegate()),
@@ -91,7 +85,7 @@ class ExploreFragment :
addItemDecoration(TypedListSpacingDecoration(context, false))
checkNotNull(sourceSelectionController).attachToRecyclerView(this)
}
- addMenuProvider(ExploreMenuProvider(binding.root.context))
+ addMenuProvider(ExploreMenuProvider(router))
viewModel.content.observe(viewLifecycleOwner, checkNotNull(exploreAdapter))
viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(binding.recyclerView, this))
viewModel.onOpenManga.observeEvent(viewLifecycleOwner, ::onOpenManga)
@@ -117,49 +111,42 @@ class ExploreFragment :
override fun onListHeaderClick(item: ListHeader, view: View) {
if (item.payload == R.id.nav_suggestions) {
- startActivity(SuggestionsActivity.newIntent(view.context))
+ router.openSuggestions()
+ } else if (viewModel.isAllSourcesEnabled.value) {
+ router.openManageSources()
} else {
- startActivity(Intent(view.context, SourcesCatalogActivity::class.java))
+ router.openSourcesCatalog()
}
}
override fun onClick(v: View) {
- val intent = when (v.id) {
- R.id.button_local -> MangaListActivity.newIntent(v.context, LocalMangaSource, null)
- R.id.button_bookmarks -> AllBookmarksActivity.newIntent(v.context)
- R.id.button_more -> SuggestionsActivity.newIntent(v.context)
- R.id.button_downloads -> Intent(v.context, DownloadsActivity::class.java)
- R.id.button_random -> {
- viewModel.openRandom()
- return
- }
-
- else -> return
+ when (v.id) {
+ R.id.button_local -> router.openList(LocalMangaSource, null)
+ R.id.button_bookmarks -> router.openBookmarks()
+ R.id.button_more -> router.openSuggestions()
+ R.id.button_downloads -> router.openDownloads()
+ R.id.button_random -> viewModel.openRandom()
}
- startActivity(intent)
}
override fun onItemClick(item: MangaSourceItem, view: View) {
if (sourceSelectionController?.onItemClick(item.id) == true) {
return
}
- val intent = MangaListActivity.newIntent(view.context, item.source, null)
- startActivity(intent)
+ router.openList(item.source, null)
}
override fun onItemLongClick(item: MangaSourceItem, view: View): Boolean {
- return sourceSelectionController?.onItemLongClick(view, item.id) ?: false
+ return sourceSelectionController?.onItemLongClick(view, item.id) == true
}
override fun onItemContextClick(item: MangaSourceItem, view: View): Boolean {
- return sourceSelectionController?.onItemContextClick(view, item.id) ?: false
+ return sourceSelectionController?.onItemContextClick(view, item.id) == true
}
override fun onRetryClick(error: Throwable) = Unit
- override fun onEmptyActionClick() {
- startActivity(Intent(context ?: return, SourcesCatalogActivity::class.java))
- }
+ override fun onEmptyActionClick() = router.openSourcesCatalog()
override fun onSelectionChanged(controller: ListSelectionController, count: Int) {
viewBinding?.recyclerView?.invalidateItemDecorations()
@@ -181,7 +168,8 @@ class ExploreFragment :
menu.findItem(R.id.action_shortcut).isVisible = isSingleSelection
menu.findItem(R.id.action_pin).isVisible = selectedSources.all { !it.isPinned }
menu.findItem(R.id.action_unpin).isVisible = selectedSources.all { it.isPinned }
- menu.findItem(R.id.action_disable)?.isVisible = selectedSources.all { it.mangaSource is MangaParserSource }
+ menu.findItem(R.id.action_disable)?.isVisible = !viewModel.isAllSourcesEnabled.value &&
+ selectedSources.all { it.mangaSource is MangaParserSource }
menu.findItem(R.id.action_delete)?.isVisible = selectedSources.all { it.mangaSource is ExternalMangaSource }
return super.onPrepareActionMode(controller, mode, menu)
}
@@ -194,7 +182,7 @@ class ExploreFragment :
when (item.itemId) {
R.id.action_settings -> {
val source = selectedSources.singleOrNull() ?: return false
- startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), source))
+ router.openSourceSettings(source)
mode?.finish()
}
@@ -232,8 +220,7 @@ class ExploreFragment :
}
private fun onOpenManga(manga: Manga) {
- val intent = DetailsActivity.newIntent(context ?: return, manga)
- startActivity(intent)
+ router.openDetails(manga)
}
private fun onGridModeChanged(isGrid: Boolean) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt
index 86211b090..869ed767c 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreMenuProvider.kt
@@ -1,15 +1,14 @@
package org.koitharu.kotatsu.explore.ui
-import android.content.Context
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.settings.SettingsActivity
+import org.koitharu.kotatsu.core.nav.AppRouter
class ExploreMenuProvider(
- private val context: Context,
+ private val router: AppRouter,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -19,7 +18,7 @@ class ExploreMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
return when (menuItem.itemId) {
R.id.action_manage -> {
- context.startActivity(SettingsActivity.newSourcesSettingsIntent(context))
+ router.openSourcesSettings()
true
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
index 717d6c321..4fc9ae64a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/explore/ui/ExploreViewModel.kt
@@ -7,7 +7,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
-import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.mapLatest
@@ -23,6 +22,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.ui.util.ReversibleAction
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
+import org.koitharu.kotatsu.core.util.ext.combine
import org.koitharu.kotatsu.explore.data.MangaSourcesRepository
import org.koitharu.kotatsu.explore.domain.ExploreRepository
import org.koitharu.kotatsu.explore.ui.model.ExploreButtons
@@ -54,6 +54,12 @@ class ExploreViewModel @Inject constructor(
valueProducer = { isSourcesGridMode },
)
+ val isAllSourcesEnabled = settings.observeAsStateFlow(
+ scope = viewModelScope + Dispatchers.IO,
+ key = AppSettings.KEY_SOURCES_ENABLED_ALL,
+ valueProducer = { isAllSourcesEnabled },
+ )
+
private val isSuggestionsEnabled = settings.observeAsFlow(
key = AppSettings.KEY_SUGGESTIONS,
valueProducer = { isSuggestionsEnabled },
@@ -137,9 +143,10 @@ class ExploreViewModel @Inject constructor(
getSuggestionFlow(),
isGrid,
isRandomLoading,
+ isAllSourcesEnabled,
sourcesRepository.observeHasNewSourcesForBadge(),
- ) { content, suggestions, grid, randomLoading, newSources ->
- buildList(content, suggestions, grid, randomLoading, newSources)
+ ) { content, suggestions, grid, randomLoading, allSourcesEnabled, newSources ->
+ buildList(content, suggestions, grid, randomLoading, allSourcesEnabled, newSources)
}.withErrorHandling()
private fun buildList(
@@ -147,6 +154,7 @@ class ExploreViewModel @Inject constructor(
recommendation: List,
isGrid: Boolean,
randomLoading: Boolean,
+ allSourcesEnabled: Boolean,
hasNewSources: Boolean,
): List {
val result = ArrayList(sources.size + 3)
@@ -158,8 +166,8 @@ class ExploreViewModel @Inject constructor(
if (sources.isNotEmpty()) {
result += ListHeader(
textRes = R.string.remote_sources,
- buttonTextRes = R.string.catalog,
- badge = if (hasNewSources) "" else null,
+ buttonTextRes = if (allSourcesEnabled) R.string.manage else R.string.catalog,
+ badge = if (!allSourcesEnabled && hasNewSources) "" else null,
)
sources.mapTo(result) { MangaSourceItem(it, isGrid) }
} else {
@@ -198,6 +206,7 @@ class ExploreViewModel @Inject constructor(
counter = 0,
progress = null,
isFavorite = false,
+ isSaved = false,
)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
index a99afe241..c32e5537d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/data/EntityMapping.kt
@@ -16,6 +16,6 @@ fun FavouriteCategoryEntity.toFavouriteCategory(id: Long = categoryId.toLong())
isVisibleInLibrary = isVisibleInLibrary,
)
-fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags())
+fun FavouriteManga.toManga() = manga.toManga(tags.toMangaTags(), null)
fun Collection.toMangaList() = map { it.toManga() }
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
index c65a2acce..7e7f1f049 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/FavouritesRepository.kt
@@ -199,6 +199,7 @@ class FavouritesRepository @Inject constructor(
db.getFavouritesDao().deleteAll(id)
db.getFavouriteCategoriesDao().delete(id)
}
+ db.getChaptersDao().gc()
}
}
@@ -238,6 +239,7 @@ class FavouritesRepository @Inject constructor(
for (id in ids) {
db.getFavouritesDao().delete(mangaId = id)
}
+ db.getChaptersDao().gc()
}
return ReversibleHandle { recoverToFavourites(ids) }
}
@@ -247,6 +249,7 @@ class FavouritesRepository @Inject constructor(
for (id in ids) {
db.getFavouritesDao().delete(categoryId = categoryId, mangaId = id)
}
+ db.getChaptersDao().gc()
}
return ReversibleHandle { recoverToCategory(categoryId, ids) }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt
index 59162a210..59c201d79 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/domain/LocalFavoritesObserver.kt
@@ -32,7 +32,7 @@ class LocalFavoritesObserver @Inject constructor(
limit: Int
): Flow> = db.getFavouritesDao().observeAll(categoryId, order, filterOptions, limit).mapToLocal()
- override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags())
+ override fun toManga(e: FavouriteManga) = e.manga.toManga(e.tags.toMangaTags(), null)
override fun toResult(e: FavouriteManga, manga: Manga) = manga
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt
index dd275369d..2e0ae4937 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/FavouritesActivity.kt
@@ -1,14 +1,12 @@
package org.koitharu.kotatsu.favourites.ui
-import android.content.Context
-import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
import androidx.fragment.app.commit
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.databinding.ActivityContainerBinding
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
@@ -21,7 +19,7 @@ class FavouritesActivity : BaseActivity() {
super.onCreate(savedInstanceState)
setContentView(ActivityContainerBinding.inflate(layoutInflater))
supportActionBar?.setDisplayHomeAsUpEnabled(true)
- val categoryTitle = intent.getStringExtra(EXTRA_TITLE)
+ val categoryTitle = intent.getStringExtra(AppRouter.KEY_TITLE)
if (categoryTitle != null) {
title = categoryTitle
}
@@ -29,7 +27,7 @@ class FavouritesActivity : BaseActivity() {
if (fm.findFragmentById(R.id.container) == null) {
fm.commit {
setReorderingAllowed(true)
- val fragment = FavouritesListFragment.newInstance(intent.getLongExtra(EXTRA_CATEGORY_ID, NO_ID))
+ val fragment = FavouritesListFragment.newInstance(intent.getLongExtra(AppRouter.KEY_ID, NO_ID))
replace(R.id.container, fragment)
}
}
@@ -41,16 +39,4 @@ class FavouritesActivity : BaseActivity() {
right = insets.right,
)
}
-
- companion object {
-
- private const val EXTRA_CATEGORY_ID = "cat_id"
- private const val EXTRA_TITLE = "title"
-
- fun newIntent(context: Context) = Intent(context, FavouritesActivity::class.java)
-
- fun newIntent(context: Context, category: FavouriteCategory) = Intent(context, FavouritesActivity::class.java)
- .putExtra(EXTRA_CATEGORY_ID, category.id)
- .putExtra(EXTRA_TITLE, category.title)
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt
index e8366ad69..641622af9 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/CategoriesSelectionCallback.kt
@@ -46,16 +46,6 @@ class CategoriesSelectionCallback(
override fun onActionItemClicked(controller: ListSelectionController, mode: ActionMode?, item: MenuItem): Boolean {
return when (item.itemId) {
- /*R.id.action_view -> {
- val id = controller.peekCheckedIds().singleOrNull() ?: return false
- val context = recyclerView.context
- val category = viewModel.getCategory(id) ?: return false
- val intent = FavouritesActivity.newIntent(context, category)
- context.startActivity(intent)
- mode.finish()
- true
- }*/
-
R.id.action_show -> {
viewModel.setIsVisible(controller.snapshot(), true)
mode?.finish()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt
index b9073cf47..9965b7be6 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/FavouriteCategoriesActivity.kt
@@ -15,14 +15,13 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityCategoriesBinding
-import org.koitharu.kotatsu.favourites.ui.FavouritesActivity
import org.koitharu.kotatsu.favourites.ui.categories.adapter.CategoriesAdapter
-import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener
import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration
import org.koitharu.kotatsu.list.ui.model.ListModel
@@ -71,30 +70,28 @@ class FavouriteCategoriesActivity :
override fun onClick(v: View) {
when (v.id) {
- R.id.fab_add -> startActivity(FavouritesCategoryEditActivity.newIntent(this))
+ R.id.fab_add -> router.openFavoriteCategoryCreate()
}
}
override fun onItemClick(item: FavouriteCategory?, view: View) {
if (item == null) {
if (selectionController.count == 0) {
- startActivity(FavouritesActivity.newIntent(view.context))
+ router.openFavorites()
}
return
}
if (selectionController.onItemClick(item.id)) {
return
}
- val intent = FavouritesActivity.newIntent(view.context, item)
- startActivity(intent)
+ router.openFavorites(item)
}
override fun onEditClick(item: FavouriteCategory, view: View) {
if (selectionController.onItemClick(item.id)) {
return
}
- val intent = FavouritesCategoryEditActivity.newIntent(view.context, item.id)
- startActivity(intent)
+ router.openFavoriteCategoryEdit(item.id)
}
override fun onItemLongClick(item: FavouriteCategory?, view: View): Boolean {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
index c309c2df9..0c651a94e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditActivity.kt
@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.favourites.ui.categories.edit
import android.content.Context
-import android.content.Intent
import android.os.Bundle
import android.text.Editable
import android.view.View
@@ -68,8 +67,8 @@ class FavouritesCategoryEditActivity :
override fun onRestoreInstanceState(savedInstanceState: Bundle) {
super.onRestoreInstanceState(savedInstanceState)
- savedInstanceState.getSerializableCompat(KEY_SORT_ORDER)?.let {
- selectedSortOrder = it
+ savedInstanceState.getSerializableCompat(KEY_SORT_ORDER)?.let {
+ selectedSortOrder = it
}
}
@@ -114,8 +113,8 @@ class FavouritesCategoryEditActivity :
selectedSortOrder = category?.order
val sortText = getString((category?.order ?: ListSortOrder.NEWEST).titleResId)
viewBinding.editSort.setText(sortText, false)
- viewBinding.switchTracker.setChecked(category?.isTrackingEnabled ?: true, false)
- viewBinding.switchShelf.setChecked(category?.isVisibleInLibrary ?: true, false)
+ viewBinding.switchTracker.setChecked(category?.isTrackingEnabled != false, false)
+ viewBinding.switchShelf.setChecked(category?.isVisibleInLibrary != false, false)
}
private fun onError(e: Throwable) {
@@ -162,13 +161,7 @@ class FavouritesCategoryEditActivity :
companion object {
- const val EXTRA_ID = "id"
const val NO_ID = -1L
private const val KEY_SORT_ORDER = "sort"
-
- fun newIntent(context: Context, id: Long = NO_ID): Intent {
- return Intent(context, FavouritesCategoryEditActivity::class.java)
- .putExtra(EXTRA_ID, id)
- }
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
index 6c9e8161f..c77c3320d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/edit/FavouritesCategoryEditViewModel.kt
@@ -10,12 +10,12 @@ import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.FavouriteCategory
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
-import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.EXTRA_ID
import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity.Companion.NO_ID
import org.koitharu.kotatsu.list.domain.ListSortOrder
import javax.inject.Inject
@@ -27,7 +27,7 @@ class FavouritesCategoryEditViewModel @Inject constructor(
private val settings: AppSettings,
) : BaseViewModel() {
- private val categoryId = savedStateHandle[EXTRA_ID] ?: NO_ID
+ private val categoryId = savedStateHandle[AppRouter.KEY_ID] ?: NO_ID
val onSaved = MutableEventFlow()
val category = MutableStateFlow(null)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialog.kt
new file mode 100644
index 000000000..73b64a2d9
--- /dev/null
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialog.kt
@@ -0,0 +1,129 @@
+package org.koitharu.kotatsu.favourites.ui.categories.select
+
+import android.content.DialogInterface
+import android.content.res.ColorStateList
+import android.graphics.Color
+import android.graphics.drawable.ColorDrawable
+import android.os.Bundle
+import android.view.LayoutInflater
+import android.view.View
+import android.view.ViewGroup
+import android.widget.Toast
+import androidx.core.graphics.ColorUtils
+import androidx.core.view.isVisible
+import androidx.core.widget.ImageViewCompat
+import androidx.fragment.app.viewModels
+import coil3.ImageLoader
+import coil3.request.allowRgb565
+import coil3.request.crossfade
+import coil3.request.error
+import coil3.request.fallback
+import coil3.request.placeholder
+import com.google.android.material.checkbox.MaterialCheckBox
+import com.google.android.material.dialog.MaterialAlertDialogBuilder
+import dagger.hilt.android.AndroidEntryPoint
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.router
+import org.koitharu.kotatsu.core.ui.AlertDialogFragment
+import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
+import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
+import org.koitharu.kotatsu.core.util.ext.enqueueWith
+import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
+import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
+import org.koitharu.kotatsu.core.util.ext.getThemeColor
+import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
+import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
+import org.koitharu.kotatsu.core.util.ext.newImageRequest
+import org.koitharu.kotatsu.core.util.ext.observe
+import org.koitharu.kotatsu.core.util.ext.observeEvent
+import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding
+import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
+import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
+import javax.inject.Inject
+
+@AndroidEntryPoint
+class FavoriteDialog : AlertDialogFragment(),
+ OnListItemClickListener, DialogInterface.OnClickListener {
+
+ private val viewModel by viewModels()
+
+ @Inject
+ lateinit var coil: ImageLoader
+
+ override fun onCreateViewBinding(
+ inflater: LayoutInflater,
+ container: ViewGroup?,
+ ) = SheetFavoriteCategoriesBinding.inflate(inflater, container, false)
+
+ override fun onBuildDialog(builder: MaterialAlertDialogBuilder): MaterialAlertDialogBuilder {
+ return super.onBuildDialog(builder)
+ .setPositiveButton(R.string.done, null)
+ .setNeutralButton(R.string.manage, this)
+ }
+
+ override fun onViewBindingCreated(
+ binding: SheetFavoriteCategoriesBinding,
+ savedInstanceState: Bundle?,
+ ) {
+ super.onViewBindingCreated(binding, savedInstanceState)
+ val adapter = MangaCategoriesAdapter(coil, viewLifecycleOwner, this)
+ binding.recyclerViewCategories.adapter = adapter
+ viewModel.content.observe(viewLifecycleOwner, adapter)
+ viewModel.onError.observeEvent(viewLifecycleOwner, ::onError)
+ bindHeader()
+ }
+
+ override fun onItemClick(item: MangaCategoryItem, view: View) {
+ viewModel.setChecked(item.category.id, item.checkedState != MaterialCheckBox.STATE_CHECKED)
+ }
+
+ override fun onClick(dialog: DialogInterface?, which: Int) {
+ router.openFavoriteCategories()
+ }
+
+ private fun onError(e: Throwable) {
+ Toast.makeText(context ?: return, e.getDisplayMessage(resources), Toast.LENGTH_SHORT).show()
+ }
+
+ private fun bindHeader() {
+ val manga = viewModel.manga
+ val binding = viewBinding ?: return
+ val backgroundColor = binding.root.context.getThemeColor(android.R.attr.colorBackground)
+ ImageViewCompat.setImageTintList(
+ binding.imageViewCover3,
+ ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)),
+ )
+ ImageViewCompat.setImageTintList(
+ binding.imageViewCover2,
+ ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)),
+ )
+ binding.imageViewCover2.backgroundTintList =
+ ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76))
+ binding.imageViewCover3.backgroundTintList =
+ ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153))
+ val fallback = ColorDrawable(Color.TRANSPARENT)
+ val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3)
+ val crossFadeDuration = binding.root.context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt()
+
+ binding.textViewTitle.text = manga.joinToStringWithLimit(binding.root.context, 92) { it.title }
+
+ repeat(coverViews.size) { i ->
+ val m = manga.getOrNull(i)
+ val view = coverViews[i]
+ view.isVisible = m != null
+ if (m == null) {
+ view.disposeImageRequest()
+ } else {
+ view.newImageRequest(viewLifecycleOwner, m.coverUrl)?.run {
+ placeholder(R.drawable.ic_placeholder)
+ fallback(fallback)
+ mangaSourceExtra(m.source)
+ crossfade(crossFadeDuration * (i + 1))
+ error(R.drawable.ic_error_placeholder)
+ allowRgb565(true)
+ enqueueWith(coil)
+ }
+ }
+ }
+ }
+}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialogViewModel.kt
similarity index 71%
rename from app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt
rename to app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialogViewModel.kt
index 31442cc42..a922d9967 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheetViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteDialogViewModel.kt
@@ -4,6 +4,7 @@ import androidx.collection.MutableLongObjectMap
import androidx.collection.MutableLongSet
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.viewModelScope
+import com.google.android.material.checkbox.MaterialCheckBox
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
@@ -11,40 +12,33 @@ import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.plus
+import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.core.model.ids
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.observeAsFlow
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
import org.koitharu.kotatsu.favourites.domain.FavouritesRepository
-import org.koitharu.kotatsu.favourites.domain.model.Cover
-import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
+import org.koitharu.kotatsu.list.ui.model.EmptyState
import org.koitharu.kotatsu.list.ui.model.ListModel
-import org.koitharu.kotatsu.parsers.util.mapToSet
+import org.koitharu.kotatsu.list.ui.model.LoadingState
import javax.inject.Inject
@HiltViewModel
-class FavoriteSheetViewModel @Inject constructor(
+class FavoriteDialogViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val favouritesRepository: FavouritesRepository,
settings: AppSettings,
) : BaseViewModel() {
- private val manga = savedStateHandle.require>(FavoriteSheet.KEY_MANGA_LIST).mapToSet {
+ val manga = savedStateHandle.require>(AppRouter.KEY_MANGA_LIST).map {
it.manga
}
- private val header = CategoriesHeaderItem(
- titles = manga.map { it.title },
- covers = manga.take(3).map {
- Cover(
- url = it.coverUrl,
- source = it.source.name,
- )
- },
- )
+
private val refreshTrigger = MutableStateFlow(Any())
val content = combine(
favouritesRepository.observeCategories(),
@@ -53,7 +47,7 @@ class FavoriteSheetViewModel @Inject constructor(
) { categories, _, tracker ->
mapList(categories, tracker)
}.withErrorHandling()
- .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(header))
+ .stateIn(viewModelScope + Dispatchers.Default, SharingStarted.Eagerly, listOf(LoadingState))
fun setChecked(categoryId: Long, isChecked: Boolean) {
launchJob(Dispatchers.Default) {
@@ -67,22 +61,32 @@ class FavoriteSheetViewModel @Inject constructor(
}
private suspend fun mapList(categories: List, tracker: Boolean): List {
+ if (categories.isEmpty()) {
+ return listOf(
+ EmptyState(
+ icon = 0,
+ textPrimary = R.string.empty_favourite_categories,
+ textSecondary = 0,
+ actionStringRes = 0,
+ ),
+ )
+ }
val cats = MutableLongObjectMap(categories.size)
categories.forEach { cats[it.id] = MutableLongSet(manga.size) }
for (m in manga) {
val ids = favouritesRepository.getCategoriesIds(m.id)
ids.forEach { id -> cats[id]?.add(m.id) }
}
- return buildList(categories.size + 1) {
- add(header)
- categories.mapTo(this) { cat ->
- MangaCategoryItem(
- category = cat,
- isChecked = cats[cat.id]?.isNotEmpty() == true,
- isTrackerEnabled = tracker,
- isEnabled = cats[cat.id]?.let { it.size == 0 || it.size == manga.size } == true,
- )
- }
+ return categories.map { cat ->
+ MangaCategoryItem(
+ category = cat,
+ checkedState = when (cats[cat.id]?.size ?: 0) {
+ 0 -> MaterialCheckBox.STATE_UNCHECKED
+ manga.size -> MaterialCheckBox.STATE_CHECKED
+ else -> MaterialCheckBox.STATE_INDETERMINATE
+ },
+ isTrackerEnabled = tracker,
+ )
}
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt
deleted file mode 100644
index 3f9188753..000000000
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/FavoriteSheet.kt
+++ /dev/null
@@ -1,72 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui.categories.select
-
-import android.os.Bundle
-import android.view.LayoutInflater
-import android.view.View
-import android.view.ViewGroup
-import android.widget.Toast
-import androidx.fragment.app.FragmentManager
-import androidx.fragment.app.viewModels
-import coil3.ImageLoader
-import dagger.hilt.android.AndroidEntryPoint
-import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
-import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
-import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
-import org.koitharu.kotatsu.core.util.ext.observe
-import org.koitharu.kotatsu.core.util.ext.observeEvent
-import org.koitharu.kotatsu.core.util.ext.showDistinct
-import org.koitharu.kotatsu.core.util.ext.withArgs
-import org.koitharu.kotatsu.databinding.SheetFavoriteCategoriesBinding
-import org.koitharu.kotatsu.favourites.ui.categories.select.adapter.MangaCategoriesAdapter
-import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
-import org.koitharu.kotatsu.parsers.model.Manga
-import javax.inject.Inject
-
-@AndroidEntryPoint
-class FavoriteSheet : BaseAdaptiveSheet(), OnListItemClickListener {
-
- private val viewModel by viewModels()
-
- @Inject
- lateinit var coil: ImageLoader
-
- override fun onCreateViewBinding(
- inflater: LayoutInflater,
- container: ViewGroup?,
- ) = SheetFavoriteCategoriesBinding.inflate(inflater, container, false)
-
- override fun onViewBindingCreated(
- binding: SheetFavoriteCategoriesBinding,
- savedInstanceState: Bundle?,
- ) {
- super.onViewBindingCreated(binding, savedInstanceState)
- val adapter = MangaCategoriesAdapter(coil, viewLifecycleOwner, this)
- binding.recyclerViewCategories.adapter = adapter
- viewModel.content.observe(viewLifecycleOwner, adapter)
- viewModel.onError.observeEvent(viewLifecycleOwner, ::onError)
- }
-
- override fun onItemClick(item: MangaCategoryItem, view: View) {
- viewModel.setChecked(item.category.id, !item.isChecked)
- }
-
- private fun onError(e: Throwable) {
- Toast.makeText(context ?: return, e.getDisplayMessage(resources), Toast.LENGTH_SHORT).show()
- }
-
- companion object {
-
- private const val TAG = "FavoriteSheet"
- const val KEY_MANGA_LIST = "manga_list"
-
- fun show(fm: FragmentManager, manga: Manga) = show(fm, setOf(manga))
-
- fun show(fm: FragmentManager, manga: Collection) = FavoriteSheet().withArgs(1) {
- putParcelableArrayList(
- KEY_MANGA_LIST,
- manga.mapTo(ArrayList(manga.size)) { ParcelableManga(it, withDescription = false) },
- )
- }.showDistinct(fm, TAG)
- }
-}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt
deleted file mode 100644
index 6f8fc332b..000000000
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/CategoriesHeaderAD.kt
+++ /dev/null
@@ -1,91 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui.categories.select.adapter
-
-import android.content.Intent
-import android.content.res.ColorStateList
-import android.graphics.Color
-import android.graphics.drawable.ColorDrawable
-import android.view.View
-import androidx.core.graphics.ColorUtils
-import androidx.core.view.isVisible
-import androidx.core.widget.ImageViewCompat
-import androidx.lifecycle.LifecycleOwner
-import coil3.ImageLoader
-import coil3.request.allowRgb565
-import coil3.request.crossfade
-import coil3.request.error
-import coil3.request.fallback
-import coil3.request.placeholder
-import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
-import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
-import org.koitharu.kotatsu.core.util.ext.enqueueWith
-import org.koitharu.kotatsu.core.util.ext.getAnimationDuration
-import org.koitharu.kotatsu.core.util.ext.getThemeColor
-import org.koitharu.kotatsu.core.util.ext.joinToStringWithLimit
-import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
-import org.koitharu.kotatsu.core.util.ext.newImageRequest
-import org.koitharu.kotatsu.databinding.ItemCategoriesHeaderBinding
-import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
-import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
-import org.koitharu.kotatsu.favourites.ui.categories.select.model.CategoriesHeaderItem
-import org.koitharu.kotatsu.list.ui.model.ListModel
-
-fun categoriesHeaderAD(
- coil: ImageLoader,
- lifecycleOwner: LifecycleOwner,
-) = adapterDelegateViewBinding(
- { inflater, parent -> ItemCategoriesHeaderBinding.inflate(inflater, parent, false) },
-) {
-
- val onClickListener = View.OnClickListener { v ->
- val intent = when (v.id) {
- R.id.chip_create -> FavouritesCategoryEditActivity.newIntent(v.context)
- R.id.chip_manage -> Intent(v.context, FavouriteCategoriesActivity::class.java)
- else -> return@OnClickListener
- }
- v.context.startActivity(intent)
- }
-
- binding.chipCreate.setOnClickListener(onClickListener)
- binding.chipManage.setOnClickListener(onClickListener)
-
- val backgroundColor = context.getThemeColor(android.R.attr.colorBackground)
- ImageViewCompat.setImageTintList(
- binding.imageViewCover3,
- ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153)),
- )
- ImageViewCompat.setImageTintList(
- binding.imageViewCover2,
- ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76)),
- )
- binding.imageViewCover2.backgroundTintList =
- ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 76))
- binding.imageViewCover3.backgroundTintList =
- ColorStateList.valueOf(ColorUtils.setAlphaComponent(backgroundColor, 153))
- val fallback = ColorDrawable(Color.TRANSPARENT)
- val coverViews = arrayOf(binding.imageViewCover1, binding.imageViewCover2, binding.imageViewCover3)
- val crossFadeDuration = context.getAnimationDuration(R.integer.config_defaultAnimTime).toInt()
-
- bind {
- binding.textViewTitle.text = item.titles.joinToStringWithLimit(context, 120) { it }
-
- repeat(coverViews.size) { i ->
- val cover = item.covers.getOrNull(i)
- val view = coverViews[i]
- view.isVisible = cover != null
- if (cover == null) {
- view.disposeImageRequest()
- } else {
- view.newImageRequest(lifecycleOwner, cover.url)?.run {
- placeholder(R.drawable.ic_placeholder)
- fallback(fallback)
- mangaSourceExtra(cover.mangaSource)
- crossfade(crossFadeDuration * (i + 1))
- error(R.drawable.ic_error_placeholder)
- allowRgb565(true)
- enqueueWith(coil)
- }
- }
- }
- }
-}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt
index f820d6994..cb697f552 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoriesAdapter.kt
@@ -5,6 +5,9 @@ import coil3.ImageLoader
import org.koitharu.kotatsu.core.ui.BaseListAdapter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
+import org.koitharu.kotatsu.list.ui.adapter.ListItemType
+import org.koitharu.kotatsu.list.ui.adapter.emptyStateListAD
+import org.koitharu.kotatsu.list.ui.adapter.loadingStateAD
import org.koitharu.kotatsu.list.ui.model.ListModel
class MangaCategoriesAdapter(
@@ -14,7 +17,8 @@ class MangaCategoriesAdapter(
) : BaseListAdapter() {
init {
- delegatesManager.addDelegate(mangaCategoryAD(clickListener))
- .addDelegate(categoriesHeaderAD(coil, lifecycleOwner))
+ addDelegate(ListItemType.NAV_ITEM, mangaCategoryAD(clickListener))
+ addDelegate(ListItemType.STATE_LOADING, loadingStateAD())
+ addDelegate(ListItemType.STATE_EMPTY, emptyStateListAD(coil, lifecycleOwner, null))
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt
index 57c0f051a..3338f5068 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/adapter/MangaCategoryAD.kt
@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.favourites.ui.categories.select.adapter
-import androidx.core.view.isGone
-import androidx.core.view.isVisible
+import androidx.core.text.buildSpannedString
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.model.appendIcon
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
-import org.koitharu.kotatsu.core.util.ext.setChecked
import org.koitharu.kotatsu.databinding.ItemCategoryCheckableBinding
import org.koitharu.kotatsu.favourites.ui.categories.select.model.MangaCategoryItem
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
@@ -21,11 +21,20 @@ fun mangaCategoryAD(
}
bind { payloads ->
- binding.root.isEnabled = item.isEnabled
- binding.checkableImageView.isEnabled = item.isEnabled
- binding.checkableImageView.setChecked(item.isChecked, ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED in payloads)
- binding.textViewTitle.text = item.category.title
- binding.imageViewTracker.isVisible = item.category.isTrackingEnabled && item.isTrackerEnabled
- binding.imageViewHidden.isGone = item.category.isVisibleInLibrary
+ binding.checkBox.checkedState = item.checkedState
+ if (ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED !in payloads) {
+ binding.checkBox.text = buildSpannedString {
+ append(item.category.title)
+ if (item.isTrackerEnabled && item.category.isTrackingEnabled) {
+ append(' ')
+ appendIcon(binding.checkBox, R.drawable.ic_notification)
+ }
+ if (!item.category.isVisibleInLibrary) {
+ append(' ')
+ appendIcon(binding.checkBox, R.drawable.ic_eye_off)
+ }
+ }
+ binding.checkBox.jumpDrawablesToCurrentState()
+ }
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt
deleted file mode 100644
index 7c294476d..000000000
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/CategoriesHeaderItem.kt
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.koitharu.kotatsu.favourites.ui.categories.select.model
-
-import org.koitharu.kotatsu.favourites.domain.model.Cover
-import org.koitharu.kotatsu.list.ui.model.ListModel
-
-data class CategoriesHeaderItem(
- val titles: List,
- val covers: List,
-) : ListModel {
-
- override fun areItemsTheSame(other: ListModel): Boolean {
- return other is CategoriesHeaderItem
- }
-
- override fun equals(other: Any?): Boolean {
- if (this === other) return true
- return javaClass == other?.javaClass
- }
-
- override fun hashCode(): Int {
- return javaClass.hashCode()
- }
-}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt
index 0b76176a9..102200487 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/categories/select/model/MangaCategoryItem.kt
@@ -1,13 +1,13 @@
package org.koitharu.kotatsu.favourites.ui.categories.select.model
+import com.google.android.material.checkbox.MaterialCheckBox.CheckedState
import org.koitharu.kotatsu.core.model.FavouriteCategory
import org.koitharu.kotatsu.list.ui.ListModelDiffCallback
import org.koitharu.kotatsu.list.ui.model.ListModel
data class MangaCategoryItem(
val category: FavouriteCategory,
- val isChecked: Boolean,
- val isEnabled: Boolean,
+ @CheckedState val checkedState: Int,
val isTrackerEnabled: Boolean,
) : ListModel {
@@ -16,7 +16,7 @@ data class MangaCategoryItem(
}
override fun getChangePayload(previousState: ListModel): Any? {
- return if (previousState is MangaCategoryItem && previousState.isChecked != isChecked) {
+ return if (previousState is MangaCategoryItem && previousState.checkedState != checkedState) {
ListModelDiffCallback.PAYLOAD_CHECKED_CHANGED
} else {
super.getChangePayload(previousState)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabPopupMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabPopupMenuProvider.kt
index dbd29e6c4..9fcaa78c9 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabPopupMenuProvider.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouriteTabPopupMenuProvider.kt
@@ -6,12 +6,13 @@ import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
-import org.koitharu.kotatsu.favourites.ui.categories.edit.FavouritesCategoryEditActivity
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment.Companion.NO_ID
class FavouriteTabPopupMenuProvider(
private val context: Context,
+ private val router: AppRouter,
private val viewModel: FavouritesContainerViewModel,
private val categoryId: Long
) : MenuProvider {
@@ -28,12 +29,8 @@ class FavouriteTabPopupMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_hide -> viewModel.hide(categoryId)
- R.id.action_edit -> context.startActivity(
- FavouritesCategoryEditActivity.newIntent(context, categoryId),
- )
-
+ R.id.action_edit -> router.openFavoriteCategoryEdit(categoryId)
R.id.action_delete -> confirmDelete()
-
else -> return false
}
return true
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt
index 217a4edb9..05f77fd83 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerFragment.kt
@@ -1,6 +1,5 @@
package org.koitharu.kotatsu.favourites.ui.container
-import android.content.Intent
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
@@ -16,6 +15,7 @@ import coil3.ImageLoader
import com.google.android.material.tabs.TabLayoutMediator
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.util.ActionModeListener
import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver
@@ -29,7 +29,6 @@ import org.koitharu.kotatsu.core.util.ext.setTabsEnabled
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
import org.koitharu.kotatsu.databinding.FragmentFavouritesContainerBinding
import org.koitharu.kotatsu.databinding.ItemEmptyStateBinding
-import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
import javax.inject.Inject
@AndroidEntryPoint
@@ -55,13 +54,13 @@ class FavouritesContainerFragment : BaseFragment startActivity(
- Intent(v.context, FavouriteCategoriesActivity::class.java),
- )
+ R.id.button_retry -> router.openFavoriteCategories()
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerMenuProvider.kt
index 1605204e4..f8c011f29 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerMenuProvider.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesContainerMenuProvider.kt
@@ -1,16 +1,14 @@
package org.koitharu.kotatsu.favourites.ui.container
-import android.content.Context
-import android.content.Intent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.favourites.ui.categories.FavouriteCategoriesActivity
+import org.koitharu.kotatsu.core.nav.AppRouter
class FavouritesContainerMenuProvider(
- private val context: Context,
+ private val router: AppRouter,
) : MenuProvider {
override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) {
@@ -20,7 +18,7 @@ class FavouritesContainerMenuProvider(
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
when (menuItem.itemId) {
R.id.action_manage -> {
- context.startActivity(Intent(context, FavouriteCategoriesActivity::class.java))
+ router.openFavoriteCategories()
}
else -> return false
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesTabConfigurationStrategy.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesTabConfigurationStrategy.kt
index c3fc540b1..2d3c9e74f 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesTabConfigurationStrategy.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/container/FavouritesTabConfigurationStrategy.kt
@@ -3,17 +3,19 @@ package org.koitharu.kotatsu.favourites.ui.container
import com.google.android.material.tabs.TabLayout
import com.google.android.material.tabs.TabLayoutMediator.TabConfigurationStrategy
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
class FavouritesTabConfigurationStrategy(
private val adapter: FavouritesContainerAdapter,
private val viewModel: FavouritesContainerViewModel,
+ private val router: AppRouter,
) : TabConfigurationStrategy {
override fun onConfigureTab(tab: TabLayout.Tab, position: Int) {
val item = adapter.getItem(position)
tab.text = item.title ?: tab.view.context.getString(R.string.all_favourites)
tab.tag = item
- PopupMenuMediator(FavouriteTabPopupMenuProvider(tab.view.context, viewModel, item.id)).attach(tab.view)
+ PopupMenuMediator(FavouriteTabPopupMenuProvider(tab.view.context, router, viewModel, item.id)).attach(tab.view)
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
index fd559caa3..5abe8a0b9 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/favourites/ui/list/FavouritesListViewModel.kt
@@ -144,7 +144,7 @@ class FavouritesListViewModel @Inject constructor(
}
val result = ArrayList(size + 1)
quickFilter.filterItem(filters)?.let(result::add)
- mangaListMapper.toListModelList(result, this, mode)
+ mangaListMapper.toListModelList(result, this, mode, MangaListMapper.NO_FAVORITE)
return result
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt
index ff7d30917..ca1e30837 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/FilterHeaderFragment.kt
@@ -9,13 +9,13 @@ import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.flowOn
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.databinding.FragmentFilterHeaderBinding
import org.koitharu.kotatsu.filter.ui.model.FilterHeaderModel
-import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
@@ -54,7 +54,7 @@ class FilterHeaderFragment : BaseFragment(), ChipsV
when (data) {
is MangaTag -> filter.toggleTag(data, !chip.isChecked)
is String -> Unit
- null -> TagsCatalogSheet.show(parentFragmentManager, isExcludeTag = false)
+ null -> router.showTagsCatalogSheet(excludeMode = false)
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt
index f85bd4485..ab69c39a7 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/sheet/FilterSheetFragment.kt
@@ -9,13 +9,12 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import androidx.core.view.isGone
import androidx.core.view.updatePadding
-import androidx.fragment.app.Fragment
-import androidx.fragment.app.FragmentManager
import com.google.android.material.chip.Chip
import com.google.android.material.slider.RangeSlider
import com.google.android.material.slider.Slider
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.titleResId
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.model.titleRes
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
@@ -25,11 +24,9 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.parentView
import org.koitharu.kotatsu.core.util.ext.setValueRounded
import org.koitharu.kotatsu.core.util.ext.setValuesRounded
-import org.koitharu.kotatsu.core.util.ext.showDistinct
import org.koitharu.kotatsu.databinding.SheetFilterBinding
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.model.FilterProperty
-import org.koitharu.kotatsu.filter.ui.tags.TagsCatalogSheet
import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.ContentType
import org.koitharu.kotatsu.parsers.model.Demographic
@@ -88,10 +85,10 @@ class FilterSheetFragment : BaseAdaptiveSheet(),
binding.sliderYear.addOnChangeListener(this::onSliderValueChange)
binding.sliderYearsRange.addOnChangeListener(this::onRangeSliderValueChange)
binding.layoutGenres.setOnMoreButtonClickListener {
- TagsCatalogSheet.show(getChildFragmentManager(), isExcludeTag = false)
+ router.showTagsCatalogSheet(excludeMode = false)
}
binding.layoutGenresExclude.setOnMoreButtonClickListener {
- TagsCatalogSheet.show(getChildFragmentManager(), isExcludeTag = true)
+ router.showTagsCatalogSheet(excludeMode = true)
}
}
@@ -153,7 +150,7 @@ class FilterSheetFragment : BaseAdaptiveSheet(),
is ContentType -> filter.toggleContentType(data, !chip.isChecked)
is ContentRating -> filter.toggleContentRating(data, !chip.isChecked)
is Demographic -> filter.toggleDemographic(data, !chip.isChecked)
- null -> TagsCatalogSheet.show(getChildFragmentManager(), chip.parentView?.id == R.id.chips_genresExclude)
+ null -> router.showTagsCatalogSheet(excludeMode = chip.parentView?.id == R.id.chips_genresExclude)
}
}
@@ -351,13 +348,4 @@ class FilterSheetFragment : BaseAdaptiveSheet(),
}
private fun requireFilter() = (requireActivity() as FilterCoordinator.Owner).filterCoordinator
-
- companion object {
-
- private const val TAG = "FilterSheet"
-
- fun show(fm: FragmentManager) = FilterSheetFragment().showDistinct(fm, TAG)
-
- fun isSupported(fragment: Fragment) = fragment.activity is FilterCoordinator.Owner
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt
index 24e1e529e..f209ab7ea 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/filter/ui/tags/TagsCatalogSheet.kt
@@ -2,38 +2,40 @@ package org.koitharu.kotatsu.filter.ui.tags
import android.os.Bundle
import android.text.Editable
-import android.text.TextWatcher
import android.view.KeyEvent
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.widget.TextView
-import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import dagger.hilt.android.lifecycle.withCreationCallback
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetBehavior
import org.koitharu.kotatsu.core.ui.sheet.AdaptiveSheetCallback
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
+import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher
import org.koitharu.kotatsu.core.util.ext.observe
-import org.koitharu.kotatsu.core.util.ext.showDistinct
-import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.SheetTagsBinding
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
import org.koitharu.kotatsu.filter.ui.model.TagCatalogItem
@AndroidEntryPoint
-class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickListener, TextWatcher,
- AdaptiveSheetCallback, View.OnFocusChangeListener, TextView.OnEditorActionListener {
+class TagsCatalogSheet : BaseAdaptiveSheet(),
+ OnListItemClickListener,
+ DefaultTextWatcher,
+ AdaptiveSheetCallback,
+ View.OnFocusChangeListener,
+ TextView.OnEditorActionListener {
private val viewModel by viewModels(
extrasProducer = {
defaultViewModelCreationExtras.withCreationCallback { factory ->
factory.create(
filter = (requireActivity() as FilterCoordinator.Owner).filterCoordinator,
- isExcludeTag = requireArguments().getBoolean(ARG_EXCLUDE),
+ isExcludeTag = requireArguments().getBoolean(AppRouter.KEY_EXCLUDE),
)
}
},
@@ -77,10 +79,6 @@ class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickL
}
}
- override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit
-
- override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit
-
override fun afterTextChanged(s: Editable?) {
val q = s?.toString().orEmpty()
viewModel.searchQuery.value = q
@@ -89,14 +87,4 @@ class TagsCatalogSheet : BaseAdaptiveSheet(), OnListItemClickL
override fun onStateChanged(sheet: View, newState: Int) {
viewBinding?.recyclerView?.isFastScrollerEnabled = newState == AdaptiveSheetBehavior.STATE_EXPANDED
}
-
- companion object {
-
- private const val TAG = "TagsCatalogSheet"
- private const val ARG_EXCLUDE = "exclude"
-
- fun show(fm: FragmentManager, isExcludeTag: Boolean) = TagsCatalogSheet().withArgs(1) {
- putBoolean(ARG_EXCLUDE, isExcludeTag)
- }.showDistinct(fm, TAG)
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt
index 7deeea3c9..e1e807c86 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryLocalObserver.kt
@@ -24,7 +24,7 @@ class HistoryLocalObserver @Inject constructor(
limit: Int
) = db.getHistoryDao().observeAll(order, filterOptions, limit).mapToLocal()
- override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags())
+ override fun toManga(e: HistoryWithManga) = e.manga.toManga(e.tags.toMangaTags(), null)
override fun toResult(e: HistoryWithManga, manga: Manga) = MangaWithHistory(
manga = manga,
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt
index d93f2df01..13aaf137b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/data/HistoryRepository.kt
@@ -49,7 +49,7 @@ class HistoryRepository @Inject constructor(
suspend fun getList(offset: Int, limit: Int): List {
val entities = db.getHistoryDao().findAll(offset, limit)
- return entities.map { it.manga.toManga(it.tags.toMangaTags()) }
+ return entities.map { it.toManga() }
}
suspend fun search(query: String, limit: Int): List {
@@ -63,25 +63,25 @@ class HistoryRepository @Inject constructor(
suspend fun getLastOrNull(): Manga? {
val entity = db.getHistoryDao().findAll(0, 1).firstOrNull() ?: return null
- return entity.manga.toManga(entity.tags.toMangaTags())
+ return entity.toManga()
}
fun observeLast(): Flow {
return db.getHistoryDao().observeAll(1).map {
val first = it.firstOrNull()
- first?.manga?.toManga(first.tags.toMangaTags())
+ first?.toManga()
}
}
fun observeAll(): Flow> {
return db.getHistoryDao().observeAll().mapItems {
- it.manga.toManga(it.tags.toMangaTags())
+ it.toManga()
}
}
fun observeAll(limit: Int): Flow> {
return db.getHistoryDao().observeAll(limit).mapItems {
- it.manga.toManga(it.tags.toMangaTags())
+ it.toManga()
}
}
@@ -95,7 +95,7 @@ class HistoryRepository @Inject constructor(
}
return db.getHistoryDao().observeAll(order, filterOptions, limit).mapItems {
MangaWithHistory(
- it.manga.toManga(it.tags.toMangaTags()),
+ it.toManga(),
it.history.toMangaHistory(),
)
}
@@ -156,16 +156,19 @@ class HistoryRepository @Inject constructor(
db.getHistoryDao().clear()
}
- suspend fun delete(manga: Manga) {
+ suspend fun delete(manga: Manga) = db.withTransaction {
db.getHistoryDao().delete(manga.id)
+ mangaRepository.gcChaptersCache()
}
- suspend fun deleteAfter(minDate: Long) {
+ suspend fun deleteAfter(minDate: Long) = db.withTransaction {
db.getHistoryDao().deleteAfter(minDate)
+ mangaRepository.gcChaptersCache()
}
- suspend fun deleteNotFavorite() {
+ suspend fun deleteNotFavorite() = db.withTransaction {
db.getHistoryDao().deleteNotFavorite()
+ mangaRepository.gcChaptersCache()
}
suspend fun delete(ids: Collection): ReversibleHandle {
@@ -173,6 +176,7 @@ class HistoryRepository @Inject constructor(
for (id in ids) {
db.getHistoryDao().delete(id)
}
+ mangaRepository.gcChaptersCache()
}
return ReversibleHandle {
recover(ids)
@@ -185,7 +189,7 @@ class HistoryRepository @Inject constructor(
*/
suspend fun deleteOrSwap(manga: Manga, alternative: Manga?) {
if (alternative == null || db.getMangaDao().update(alternative.toEntity()) <= 0) {
- db.getHistoryDao().delete(manga.id)
+ delete(manga)
}
}
@@ -229,4 +233,6 @@ class HistoryRepository @Inject constructor(
db.getHistoryDao().update(newEntity)
return newEntity
}
+
+ private fun HistoryWithManga.toManga() = manga.toManga(tags.toMangaTags(), null)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt
index 5c05fbf46..2bc1f7cfb 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryActivity.kt
@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.history.ui
-import android.content.Context
-import android.content.Intent
import android.os.Bundle
import androidx.core.graphics.Insets
import androidx.core.view.updatePadding
@@ -41,9 +39,4 @@ class HistoryActivity :
right = insets.right,
)
}
-
- companion object {
-
- fun newIntent(context: Context) = Intent(context, HistoryActivity::class.java)
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
index c283b4fba..1af145c1b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListFragment.kt
@@ -8,6 +8,7 @@ import androidx.appcompat.view.ActionMode
import androidx.fragment.app.viewModels
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper
@@ -27,7 +28,7 @@ class HistoryListFragment : MangaListFragment() {
override fun onViewBindingCreated(binding: FragmentListBinding, savedInstanceState: Bundle?) {
super.onViewBindingCreated(binding, savedInstanceState)
RecyclerScrollKeeper(binding.recyclerView).attach()
- addMenuProvider(HistoryListMenuProvider(binding.root.context, viewModel))
+ addMenuProvider(HistoryListMenuProvider(binding.root.context, router, viewModel))
viewModel.isStatsEnabled.observe(viewLifecycleOwner, MenuInvalidator(requireActivity()))
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
index 1c412c3ae..981dd2ca2 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListMenuProvider.kt
@@ -1,15 +1,14 @@
package org.koitharu.kotatsu.history.ui
import android.content.Context
-import android.content.Intent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.dialog.RememberSelectionDialogListener
import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog
-import org.koitharu.kotatsu.stats.ui.StatsActivity
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId
@@ -17,6 +16,7 @@ import java.time.temporal.ChronoUnit
class HistoryListMenuProvider(
private val context: Context,
+ private val router: AppRouter,
private val viewModel: HistoryListViewModel,
) : MenuProvider {
@@ -37,7 +37,7 @@ class HistoryListMenuProvider(
}
R.id.action_stats -> {
- context.startActivity(Intent(context, StatsActivity::class.java))
+ router.openStatistic()
true
}
@@ -46,7 +46,7 @@ class HistoryListMenuProvider(
}
private fun showClearHistoryDialog() {
- val selectionListener = RememberSelectionDialogListener(2)
+ val selectionListener = RememberSelectionDialogListener(1)
buildAlertDialog(context, isCentered = true) {
setTitle(R.string.clear_history)
setSingleChoiceItems(
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
index 81de88924..b03725673 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/HistoryListViewModel.kt
@@ -190,7 +190,7 @@ class HistoryListViewModel @Inject constructor(
prevHeader = header
}
}
- result += mangaListMapper.toListModel(manga, mode)
+ result += mangaListMapper.toListModel(manga, mode, 0)
}
if (filters.isNotEmpty() && isEmpty) {
result += getEmptyState(hasFilters = true)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt
index 48a9f5e76..7cf75d2bd 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/history/ui/util/ReadingProgressDrawable.kt
@@ -1,17 +1,18 @@
package org.koitharu.kotatsu.history.ui.util
import android.content.Context
+import android.content.res.ColorStateList
import android.graphics.Canvas
import android.graphics.Color
-import android.graphics.ColorFilter
import android.graphics.Paint
-import android.graphics.PixelFormat
import android.graphics.Rect
-import android.graphics.drawable.Drawable
+import android.os.Build
+import androidx.annotation.RequiresApi
import androidx.annotation.StyleRes
import androidx.appcompat.content.res.AppCompatResources
-import androidx.core.graphics.ColorUtils
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.ui.image.PaintDrawable
+import org.koitharu.kotatsu.core.util.ext.hasFocusStateSpecified
import org.koitharu.kotatsu.core.util.ext.scale
import org.koitharu.kotatsu.list.domain.ReadingProgress
import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
@@ -19,23 +20,29 @@ import org.koitharu.kotatsu.list.domain.ReadingProgress.Companion.PROGRESS_NONE
class ReadingProgressDrawable(
context: Context,
@StyleRes styleResId: Int,
-) : Drawable() {
+) : PaintDrawable() {
- private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
+ override val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG)
private val checkDrawable = AppCompatResources.getDrawable(context, R.drawable.ic_check)
- private val lineColor: Int
- private val outlineColor: Int
- private val backgroundColor: Int
- private val textColor: Int
+ private val lineColor: ColorStateList
+ private val outlineColor: ColorStateList
+ private val backgroundColor: ColorStateList
+ private val textColor: ColorStateList
private val textBounds = Rect()
private val tempRect = Rect()
- private val hasBackground: Boolean
- private val hasOutline: Boolean
- private val hasText: Boolean
private val desiredHeight: Int
private val desiredWidth: Int
private val autoFitTextSize: Boolean
+ private var currentLineColor: Int = Color.TRANSPARENT
+ private var currentOutlineColor: Int = Color.TRANSPARENT
+ private var currentBackgroundColor: Int = Color.TRANSPARENT
+ private var currentTextColor: Int = Color.TRANSPARENT
+ private var hasBackground: Boolean = false
+ private var hasOutline: Boolean = false
+ private var hasText: Boolean = false
+
+
var percent: Float = PROGRESS_NONE
set(value) {
field = value
@@ -54,22 +61,22 @@ class ReadingProgressDrawable(
desiredHeight = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_height, -1)
desiredWidth = ta.getDimensionPixelSize(R.styleable.ProgressDrawable_android_width, -1)
autoFitTextSize = ta.getBoolean(R.styleable.ProgressDrawable_autoFitTextSize, false)
- lineColor = ta.getColor(R.styleable.ProgressDrawable_android_strokeColor, Color.BLACK)
- outlineColor = ta.getColor(R.styleable.ProgressDrawable_outlineColor, Color.TRANSPARENT)
- backgroundColor = ColorUtils.setAlphaComponent(
- ta.getColor(R.styleable.ProgressDrawable_android_fillColor, Color.TRANSPARENT),
- (255 * ta.getFloat(R.styleable.ProgressDrawable_android_fillAlpha, 0f)).toInt(),
+ lineColor = ta.getColorStateList(R.styleable.ProgressDrawable_android_strokeColor) ?: ColorStateList.valueOf(
+ Color.BLACK,
)
- textColor = ta.getColor(R.styleable.ProgressDrawable_android_textColor, lineColor)
+ outlineColor =
+ ta.getColorStateList(R.styleable.ProgressDrawable_outlineColor) ?: ColorStateList.valueOf(Color.TRANSPARENT)
+ backgroundColor = ta.getColorStateList(R.styleable.ProgressDrawable_android_fillColor)?.withAlpha(
+ (255 * ta.getFloat(R.styleable.ProgressDrawable_android_fillAlpha, 0f)).toInt(),
+ ) ?: ColorStateList.valueOf(Color.TRANSPARENT)
+ textColor = ta.getColorStateList(R.styleable.ProgressDrawable_android_textColor) ?: lineColor
paint.strokeCap = Paint.Cap.ROUND
paint.textAlign = Paint.Align.CENTER
paint.textSize = ta.getDimension(R.styleable.ProgressDrawable_android_textSize, paint.textSize)
paint.strokeWidth = ta.getDimension(R.styleable.ProgressDrawable_strokeWidth, 1f)
ta.recycle()
- hasBackground = Color.alpha(backgroundColor) != 0
- hasOutline = Color.alpha(outlineColor) != 0
- hasText = Color.alpha(textColor) != 0 && paint.textSize > 0
- checkDrawable?.setTint(textColor)
+ checkDrawable?.setTintList(textColor)
+ onStateChange(state)
}
override fun onBoundsChange(bounds: Rect) {
@@ -91,16 +98,16 @@ class ReadingProgressDrawable(
val radius = minOf(bounds.width(), bounds.height()) / 2f
if (hasBackground) {
paint.style = Paint.Style.FILL
- paint.color = backgroundColor
+ paint.color = currentBackgroundColor
canvas.drawCircle(cx, cy, radius, paint)
}
val innerRadius = radius - paint.strokeWidth / 2f
paint.style = Paint.Style.STROKE
if (hasOutline) {
- paint.color = outlineColor
+ paint.color = currentOutlineColor
canvas.drawCircle(cx, cy, innerRadius, paint)
}
- paint.color = lineColor
+ paint.color = currentLineColor
canvas.drawArc(
cx - innerRadius,
cy - innerRadius,
@@ -119,28 +126,48 @@ class ReadingProgressDrawable(
checkDrawable.draw(canvas)
} else {
paint.style = Paint.Style.FILL
- paint.color = textColor
+ paint.color = currentTextColor
val ty = bounds.height() / 2f + textBounds.height() / 2f - textBounds.bottom
canvas.drawText(text, cx, ty, paint)
}
}
}
- override fun setAlpha(alpha: Int) {
- paint.alpha = alpha
- }
+ override fun getIntrinsicHeight() = desiredHeight
- override fun setColorFilter(colorFilter: ColorFilter?) {
- paint.colorFilter = colorFilter
- }
+ override fun getIntrinsicWidth() = desiredWidth
- @Suppress("DeprecatedCallableAddReplaceWith")
- @Deprecated("Deprecated in Java")
- override fun getOpacity() = PixelFormat.TRANSLUCENT
+ override fun isStateful(): Boolean = lineColor.isStateful ||
+ outlineColor.isStateful ||
+ backgroundColor.isStateful ||
+ textColor.isStateful ||
+ checkDrawable?.isStateful == true
- override fun getIntrinsicHeight() = desiredHeight
+ @RequiresApi(Build.VERSION_CODES.S)
+ override fun hasFocusStateSpecified(): Boolean = lineColor.hasFocusStateSpecified() ||
+ outlineColor.hasFocusStateSpecified() ||
+ backgroundColor.hasFocusStateSpecified() ||
+ textColor.hasFocusStateSpecified() ||
+ checkDrawable?.hasFocusStateSpecified() == true
- override fun getIntrinsicWidth() = desiredWidth
+ override fun onStateChange(state: IntArray): Boolean {
+ val prevLineColor = currentLineColor
+ currentLineColor = lineColor.getColorForState(state, lineColor.defaultColor)
+ val prevOutlineColor = currentOutlineColor
+ currentOutlineColor = outlineColor.getColorForState(state, outlineColor.defaultColor)
+ val prevBackgroundColor = currentBackgroundColor
+ currentBackgroundColor = backgroundColor.getColorForState(state, backgroundColor.defaultColor)
+ val prevTextColor = currentTextColor
+ currentTextColor = textColor.getColorForState(state, textColor.defaultColor)
+ hasBackground = Color.alpha(currentBackgroundColor) != 0
+ hasOutline = Color.alpha(currentOutlineColor) != 0
+ hasText = Color.alpha(currentTextColor) != 0 && paint.textSize > 0
+ return checkDrawable?.setState(state) == true ||
+ prevLineColor != currentLineColor ||
+ prevOutlineColor != currentOutlineColor ||
+ prevBackgroundColor != currentBackgroundColor ||
+ prevTextColor != currentTextColor
+ }
private fun getTextSizeForWidth(width: Float, text: String): Float {
val testTextSize = 48f
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt
index c030f6222..0a5b23bcf 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageActivity.kt
@@ -1,7 +1,5 @@
package org.koitharu.kotatsu.image.ui
-import android.content.Context
-import android.content.Intent
import android.graphics.drawable.Drawable
import android.net.Uri
import android.os.Bundle
@@ -29,6 +27,7 @@ import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.MangaSource
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseActivity
import org.koitharu.kotatsu.core.ui.util.PopupMenuMediator
import org.koitharu.kotatsu.core.util.ShareHelper
@@ -41,7 +40,6 @@ import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.databinding.ActivityImageBinding
import org.koitharu.kotatsu.databinding.ItemErrorStateBinding
-import org.koitharu.kotatsu.parsers.model.MangaSource
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -123,7 +121,7 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene
.memoryCachePolicy(CachePolicy.DISABLED)
.lifecycle(this)
.listener(this)
- .mangaSourceExtra(MangaSource(intent.getStringExtra(EXTRA_SOURCE)))
+ .mangaSourceExtra(MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)))
.target(SsivTarget(viewBinding.ssiv))
.enqueueWith(coil)
}
@@ -142,7 +140,7 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene
button.setImageDrawable(
CircularProgressDrawable(this).also {
it.setStyle(CircularProgressDrawable.LARGE)
- it.setColorSchemeColors(getThemeColor(com.google.android.material.R.attr.colorControlNormal))
+ it.setColorSchemeColors(getThemeColor(materialR.attr.colorControlNormal))
it.start()
},
)
@@ -175,15 +173,4 @@ class ImageActivity : BaseActivity(), ImageRequest.Listene
}
}
}
-
- companion object {
-
- const val EXTRA_SOURCE = "source"
-
- fun newIntent(context: Context, url: String, source: MangaSource?): Intent {
- return Intent(context, ImageActivity::class.java)
- .setData(Uri.parse(url))
- .putExtra(EXTRA_SOURCE, source?.name)
- }
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt
index 8839a6416..5b233f281 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/image/ui/ImageViewModel.kt
@@ -13,7 +13,7 @@ import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import org.koitharu.kotatsu.core.model.MangaSource
-import org.koitharu.kotatsu.core.ui.BaseActivity
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
@@ -35,9 +35,9 @@ class ImageViewModel @Inject constructor(
launchLoadingJob(Dispatchers.Default) {
val request = ImageRequest.Builder(context)
.memoryCachePolicy(CachePolicy.READ_ONLY)
- .data(savedStateHandle.require(BaseActivity.EXTRA_DATA))
+ .data(savedStateHandle.require(AppRouter.KEY_DATA))
.memoryCachePolicy(CachePolicy.DISABLED)
- .mangaSourceExtra(MangaSource(savedStateHandle[ImageActivity.EXTRA_SOURCE]))
+ .mangaSourceExtra(MangaSource(savedStateHandle[AppRouter.KEY_SOURCE]))
.build()
val bitmap = coil.execute(request).getDrawableOrThrow().toBitmap()
runInterruptible(Dispatchers.IO) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt
index 71f6a4cf0..8239ca66c 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/ListFilterOption.kt
@@ -60,6 +60,20 @@ sealed interface ListFilterOption {
get() = name
}
+ data class Branch(
+ override val titleText: String?,
+ ) : ListFilterOption {
+
+ override val titleResId: Int
+ get() = if (titleText == null) R.string.system_default else 0
+
+ override val iconResId: Int
+ get() = R.drawable.ic_language
+
+ override val groupKey: String
+ get() = "_branch"
+ }
+
data class Tag(
val tag: MangaTag
) : ListFilterOption {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListMapper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListMapper.kt
index 802833859..8d918206e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListMapper.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListMapper.kt
@@ -2,6 +2,7 @@ package org.koitharu.kotatsu.list.domain
import android.content.Context
import androidx.annotation.ColorRes
+import androidx.annotation.IntDef
import androidx.collection.MutableScatterSet
import androidx.collection.ScatterSet
import dagger.hilt.android.qualifiers.ApplicationContext
@@ -15,6 +16,7 @@ import org.koitharu.kotatsu.list.ui.model.MangaCompactListModel
import org.koitharu.kotatsu.list.ui.model.MangaDetailedListModel
import org.koitharu.kotatsu.list.ui.model.MangaGridModel
import org.koitharu.kotatsu.list.ui.model.MangaListModel
+import org.koitharu.kotatsu.local.data.index.LocalMangaIndex
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.tracker.domain.TrackingRepository
@@ -28,59 +30,74 @@ class MangaListMapper @Inject constructor(
private val trackingRepository: TrackingRepository,
private val historyRepository: HistoryRepository,
private val favouritesRepository: FavouritesRepository,
+ private val localMangaIndex: LocalMangaIndex,
) {
private val dict by lazy { readTagsDict(context) }
- suspend fun toListModelList(manga: Collection, mode: ListMode): List = manga.map {
- toListModel(it, mode)
+ suspend fun toListModelList(
+ manga: Collection,
+ mode: ListMode,
+ @Flags flags: Int
+ ): List = manga.map {
+ toListModel(it, mode, flags)
}
suspend fun toListModelList(
destination: MutableCollection,
manga: Collection,
- mode: ListMode
- ) = manga.mapTo(destination) {
- toListModel(it, mode)
+ mode: ListMode,
+ @Flags flags: Int,
+ ) {
+ manga.mapTo(destination) {
+ toListModel(it, mode, flags)
+ }
}
- suspend fun toListModel(manga: Manga, mode: ListMode): MangaListModel = when (mode) {
- ListMode.LIST -> toCompactListModel(manga)
- ListMode.DETAILED_LIST -> toDetailedListModel(manga)
- ListMode.GRID -> toGridModel(manga)
+ suspend fun toListModel(
+ manga: Manga,
+ mode: ListMode,
+ @Flags flags: Int
+ ): MangaListModel = when (mode) {
+ ListMode.LIST -> toCompactListModel(manga, flags)
+ ListMode.DETAILED_LIST -> toDetailedListModel(manga, flags)
+ ListMode.GRID -> toGridModel(manga, flags)
}
- suspend fun toCompactListModel(manga: Manga) = MangaCompactListModel(
+ suspend fun toCompactListModel(manga: Manga, @Flags flags: Int) = MangaCompactListModel(
id = manga.id,
title = manga.title,
subtitle = manga.tags.joinToString(", ") { it.title },
coverUrl = manga.coverUrl,
manga = manga,
- counter = getCounter(manga.id),
- progress = getProgress(manga.id),
- isFavorite = isFavorite(manga.id),
+ counter = getCounter(manga.id, flags),
+ progress = getProgress(manga.id, flags),
+ isFavorite = isFavorite(manga.id, flags),
+ isSaved = isSaved(manga.id, flags),
)
- suspend fun toDetailedListModel(manga: Manga) = MangaDetailedListModel(
+ suspend fun toDetailedListModel(manga: Manga, @Flags flags: Int) = MangaDetailedListModel(
id = manga.id,
title = manga.title,
subtitle = manga.altTitle,
coverUrl = manga.coverUrl,
manga = manga,
- counter = getCounter(manga.id),
- progress = getProgress(manga.id),
- isFavorite = isFavorite(manga.id),
+ counter = getCounter(manga.id, flags),
+ progress = getProgress(manga.id, flags),
+ isFavorite = isFavorite(manga.id, flags),
+ isSaved = isSaved(manga.id, flags),
tags = mapTags(manga.tags),
)
- suspend fun toGridModel(manga: Manga) = MangaGridModel(
+ suspend fun toGridModel(manga: Manga, @Flags flags: Int) = MangaGridModel(
id = manga.id,
title = manga.title,
coverUrl = manga.coverUrl,
manga = manga,
- counter = getCounter(manga.id),
- progress = getProgress(manga.id),
- isFavorite = isFavorite(manga.id),
+ counter = getCounter(manga.id, flags),
+ progress = getProgress(manga.id, flags),
+ isFavorite = isFavorite(manga.id, flags),
+ isSaved = isSaved(manga.id, flags),
)
fun mapTags(tags: Collection) = tags.map {
@@ -91,7 +108,7 @@ class MangaListMapper @Inject constructor(
)
}
- private suspend fun getCounter(mangaId: Long): Int {
+ private suspend fun getCounter(mangaId: Long, @Flags flags: Int): Int {
return if (settings.isTrackerEnabled) {
trackingRepository.getNewChaptersCount(mangaId)
} else {
@@ -99,12 +116,20 @@ class MangaListMapper @Inject constructor(
}
}
- private suspend fun getProgress(mangaId: Long): ReadingProgress? {
- return historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
+ private suspend fun getProgress(mangaId: Long, @Flags flags: Int): ReadingProgress? {
+ return if (flags.hasNoFlag(NO_PROGRESS)) {
+ historyRepository.getProgress(mangaId, settings.progressIndicatorMode)
+ } else {
+ null
+ }
+ }
+
+ private suspend fun isFavorite(mangaId: Long, @Flags flags: Int): Boolean {
+ return flags.hasNoFlag(NO_FAVORITE) && favouritesRepository.isFavorite(mangaId)
}
- private fun isFavorite(mangaId: Long): Boolean {
- return false // TODO favouritesRepository.isFavorite(mangaId)
+ private suspend fun isSaved(mangaId: Long, @Flags flags: Int): Boolean {
+ return flags.hasNoFlag(NO_SAVED) && mangaId in localMangaIndex
}
@ColorRes
@@ -128,4 +153,18 @@ class MangaListMapper @Inject constructor(
set.trim()
set
}
+
+ private fun Int.hasNoFlag(flag: Int) = this and flag == 0
+
+
+ @IntDef(0, NO_SAVED, NO_PROGRESS, NO_FAVORITE)
+ @Retention(AnnotationRetention.SOURCE)
+ annotation class Flags
+
+ companion object {
+
+ const val NO_SAVED = 1
+ const val NO_PROGRESS = 2
+ const val NO_FAVORITE = 4
+ }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt
index bb1631667..ad9d2dcf6 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/domain/MangaListQuickFilter.kt
@@ -3,8 +3,8 @@ package org.koitharu.kotatsu.list.domain
import androidx.collection.ArraySet
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
+import org.koitharu.kotatsu.core.model.toChipModel
import org.koitharu.kotatsu.core.prefs.AppSettings
-import org.koitharu.kotatsu.core.ui.widgets.ChipsView
import org.koitharu.kotatsu.list.ui.model.QuickFilter
import org.koitharu.kotatsu.parsers.util.suspendlazy.getOrNull
import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
@@ -52,14 +52,7 @@ abstract class MangaListQuickFilter(
return null
}
val availableOptions = availableFilterOptions.getOrNull()?.map { option ->
- ChipsView.ChipModel(
- title = option.titleText,
- titleResId = option.titleResId,
- icon = option.iconResId,
- iconData = option.getIconData(),
- isChecked = option in selectedOptions,
- data = option,
- )
+ option.toChipModel(isChecked = option in selectedOptions)
}.orEmpty()
return if (availableOptions.isNotEmpty()) {
QuickFilter(availableOptions)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
index 2c1cd08b2..6748514b2 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListFragment.kt
@@ -24,6 +24,7 @@ import org.koitharu.kotatsu.alternatives.ui.AutoFixService
import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
import org.koitharu.kotatsu.core.model.isLocal
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseFragment
@@ -44,9 +45,6 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.resolveDp
import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope
import org.koitharu.kotatsu.databinding.FragmentListBinding
-import org.koitharu.kotatsu.details.ui.DetailsActivity
-import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment
-import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet
import org.koitharu.kotatsu.list.domain.ListFilterOption
import org.koitharu.kotatsu.list.domain.QuickFilterListener
import org.koitharu.kotatsu.list.ui.adapter.ListItemType
@@ -60,9 +58,7 @@ import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver
import org.koitharu.kotatsu.main.ui.MainActivity
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject
@@ -125,7 +121,6 @@ abstract class MangaListFragment :
isEnabled = isSwipeRefreshEnabled
}
addMenuProvider(MangaListMenuProvider(this))
- DownloadDialogFragment.registerCallback(this, binding.recyclerView)
viewModel.listMode.observe(viewLifecycleOwner, ::onListModeChanged)
viewModel.gridScale.observe(viewLifecycleOwner, ::onGridScaleChanged)
@@ -147,31 +142,29 @@ abstract class MangaListFragment :
override fun onItemClick(item: Manga, view: View) {
if (selectionController?.onItemClick(item.id) != true) {
if ((activity as? MangaListActivity)?.showPreview(item) != true) {
- startActivity(DetailsActivity.newIntent(context ?: return, item))
+ router.openDetails(item)
}
}
}
override fun onItemLongClick(item: Manga, view: View): Boolean {
- return selectionController?.onItemLongClick(view, item.id) ?: false
+ return selectionController?.onItemLongClick(view, item.id) == true
}
override fun onItemContextClick(item: Manga, view: View): Boolean {
- return selectionController?.onItemContextClick(view, item.id) ?: false
+ return selectionController?.onItemContextClick(view, item.id) == true
}
override fun onReadClick(manga: Manga, view: View) {
if (selectionController?.onItemClick(manga.id) != true) {
- val intent = IntentBuilder(view.context).manga(manga).build()
- startActivity(intent)
+ router.openReader(manga)
}
}
override fun onTagClick(manga: Manga, tag: MangaTag, view: View) {
if (selectionController?.onItemClick(manga.id) != true) {
// TODO dialog
- val intent = MangaListActivity.newIntent(view.context, tag.source, MangaListFilter(tags = setOf(tag)))
- startActivity(intent)
+ router.openList(tag)
}
}
@@ -317,13 +310,13 @@ abstract class MangaListFragment :
}
R.id.action_favourite -> {
- FavoriteSheet.show(getChildFragmentManager(), selectedItems)
+ router.showFavoriteDialog(selectedItems)
mode?.finish()
true
}
R.id.action_save -> {
- DownloadDialogFragment.show(childFragmentManager, selectedItems)
+ router.showDownloadDialog(selectedItems, viewBinding?.recyclerView)
mode?.finish()
true
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt
index 055e79227..832e606e0 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/MangaListMenuProvider.kt
@@ -6,9 +6,9 @@ import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.favourites.ui.list.FavouritesListFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment
-import org.koitharu.kotatsu.list.ui.config.ListConfigBottomSheet
import org.koitharu.kotatsu.list.ui.config.ListConfigSection
import org.koitharu.kotatsu.suggestions.ui.SuggestionsFragment
import org.koitharu.kotatsu.tracker.ui.updates.UpdatesFragment
@@ -30,7 +30,7 @@ class MangaListMenuProvider(
is UpdatesFragment -> ListConfigSection.Updated
else -> ListConfigSection.General
}
- ListConfigBottomSheet.show(fragment.childFragmentManager, section)
+ fragment.router.showListConfigSheet(section)
true
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt
index de13d0b2b..f14394b46 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/BadgeADUtil.kt
@@ -12,16 +12,19 @@ import com.google.android.material.badge.ExperimentalBadgeUtils
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.parsers.util.nullIfEmpty
+@Deprecated("")
@CheckResult
fun View.bindBadge(badge: BadgeDrawable?, counter: Int): BadgeDrawable? {
return bindBadgeImpl(badge, null, counter)
}
+@Deprecated("")
@CheckResult
fun View.bindBadge(badge: BadgeDrawable?, text: String?): BadgeDrawable? {
return bindBadgeImpl(badge, text, 0)
}
+@Deprecated("")
fun View.clearBadge(badge: BadgeDrawable?) {
BadgeUtils.detachBadgeDrawable(badge, this)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt
index 76fa9f0d7..2c5967262 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/EmptyStateListAD.kt
@@ -1,8 +1,10 @@
package org.koitharu.kotatsu.list.ui.adapter
+import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import org.koitharu.kotatsu.core.util.ext.disposeImageRequest
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.newImageRequest
import org.koitharu.kotatsu.core.util.ext.setTextAndVisible
@@ -23,7 +25,13 @@ fun emptyStateListAD(
}
bind {
- binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil)
+ if (item.icon == 0) {
+ binding.icon.isVisible = false
+ binding.icon.disposeImageRequest()
+ } else {
+ binding.icon.isVisible = true
+ binding.icon.newImageRequest(lifecycleOwner, item.icon)?.enqueueWith(coil)
+ }
binding.textPrimary.setText(item.textPrimary)
binding.textSecondary.setTextAndVisible(item.textSecondary)
if (listener != null) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
index 395b294f1..79a37cb1f 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaGridItemAD.kt
@@ -5,8 +5,8 @@ import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import coil3.request.allowRgb565
import coil3.request.transformations
-import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
+import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
@@ -30,7 +30,6 @@ fun mangaGridItemAD(
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemMangaGridBinding.inflate(inflater, parent, false) },
) {
- var badge: BadgeDrawable? = null
AdapterDelegateClickListenerAdapter(this, clickListener, MangaGridModel::manga).attach(itemView)
sizeResolver.attachToView(lifecycleOwner, itemView, binding.textViewTitle, binding.progressView)
@@ -38,7 +37,12 @@ fun mangaGridItemAD(
bind { payloads ->
binding.textViewTitle.text = item.title
binding.progressView.setProgress(item.progress, PAYLOAD_PROGRESS_CHANGED in payloads)
- binding.imageViewFavorite.isVisible = item.isFavorite
+ with(binding.iconsView) {
+ clearIcons()
+ if (item.isSaved) addIcon(R.drawable.ic_storage)
+ if (item.isFavorite) addIcon(R.drawable.ic_heart_outline)
+ isVisible = iconsCount > 0
+ }
binding.imageViewCover.newImageRequest(lifecycleOwner, item.coverUrl)?.run {
size(CoverSizeResolver(binding.imageViewCover))
defaultPlaceholders(context)
@@ -47,6 +51,7 @@ fun mangaGridItemAD(
mangaExtra(item.manga)
enqueueWith(coil)
}
- badge = itemView.bindBadge(badge, item.counter)
+ binding.badge.number = item.counter
+ binding.badge.isVisible = item.counter > 0
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
index f56a8916f..8d6aa7688 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListDetailedItemAD.kt
@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.list.ui.adapter
+import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import coil3.request.allowRgb565
import coil3.request.transformations
-import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
@@ -26,7 +26,6 @@ fun mangaListDetailedItemAD(
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemMangaListDetailsBinding.inflate(inflater, parent, false) },
) {
- var badge: BadgeDrawable? = null
AdapterDelegateClickListenerAdapter(this, clickListener, MangaDetailedListModel::manga).attach(itemView)
@@ -46,6 +45,7 @@ fun mangaListDetailedItemAD(
enqueueWith(coil)
}
binding.textViewTags.text = item.tags.joinToString(separator = ", ") { it.title ?: "" }
- badge = itemView.bindBadge(badge, item.counter)
+ binding.badge.number = item.counter
+ binding.badge.isVisible = item.counter > 0
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt
index a11da7f82..c8ff4a504 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/adapter/MangaListItemAD.kt
@@ -1,10 +1,10 @@
package org.koitharu.kotatsu.list.ui.adapter
+import androidx.core.view.isVisible
import androidx.lifecycle.LifecycleOwner
import coil3.ImageLoader
import coil3.request.allowRgb565
import coil3.request.transformations
-import com.google.android.material.badge.BadgeDrawable
import com.hannesdorfmann.adapterdelegates4.dsl.adapterDelegateViewBinding
import org.koitharu.kotatsu.core.ui.image.TrimTransformation
import org.koitharu.kotatsu.core.ui.list.AdapterDelegateClickListenerAdapter
@@ -26,7 +26,6 @@ fun mangaListItemAD(
) = adapterDelegateViewBinding(
{ inflater, parent -> ItemMangaListBinding.inflate(inflater, parent, false) },
) {
- var badge: BadgeDrawable? = null
AdapterDelegateClickListenerAdapter(this, clickListener, MangaCompactListModel::manga).attach(itemView)
@@ -40,6 +39,7 @@ fun mangaListItemAD(
mangaExtra(item.manga)
enqueueWith(coil)
}
- badge = itemView.bindBadge(badge, item.counter)
+ binding.badge.number = item.counter
+ binding.badge.isVisible = item.counter > 0
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt
index bc7c45a5b..e8a8ff20a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigBottomSheet.kt
@@ -8,7 +8,6 @@ import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.CompoundButton
import androidx.core.view.isVisible
-import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.button.MaterialButtonToggleGroup
import com.google.android.material.slider.Slider
@@ -17,8 +16,6 @@ import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet
import org.koitharu.kotatsu.core.util.ext.setValueRounded
-import org.koitharu.kotatsu.core.util.ext.showDistinct
-import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.core.util.progress.IntPercentLabelFormatter
import org.koitharu.kotatsu.databinding.SheetListModeBinding
@@ -113,14 +110,4 @@ class ListConfigBottomSheet :
}
override fun onNothingSelected(parent: AdapterView<*>?) = Unit
-
- companion object {
-
- private const val TAG = "ListModeSelectDialog"
- const val ARG_SECTION = "section"
-
- fun show(fm: FragmentManager, section: ListConfigSection) = ListConfigBottomSheet().withArgs(1) {
- putParcelable(ARG_SECTION, section)
- }.showDistinct(fm, TAG)
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt
index c82a39e69..75297e9f0 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/config/ListConfigViewModel.kt
@@ -3,6 +3,7 @@ package org.koitharu.kotatsu.list.ui.config
import androidx.lifecycle.SavedStateHandle
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.runBlocking
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.ui.BaseViewModel
@@ -21,7 +22,7 @@ class ListConfigViewModel @Inject constructor(
private val favouritesRepository: FavouritesRepository,
) : BaseViewModel() {
- val section = savedStateHandle.require(ListConfigBottomSheet.ARG_SECTION)
+ val section = savedStateHandle.require(AppRouter.KEY_LIST_SECTION)
var listMode: ListMode
get() = when (section) {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt
index 72c78c0ac..940dec657 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/ListHeader.kt
@@ -4,7 +4,6 @@ import android.content.Context
import androidx.annotation.StringRes
import org.koitharu.kotatsu.core.ui.model.DateTimeAgo
-@ExposedCopyVisibility
data class ListHeader private constructor(
private val textRaw: Any,
@StringRes val buttonTextRes: Int,
@@ -36,7 +35,7 @@ data class ListHeader private constructor(
fun getText(context: Context): CharSequence? = when (textRaw) {
is CharSequence -> textRaw
is Int -> if (textRaw != 0) context.getString(textRaw) else null
- is DateTimeAgo -> textRaw.format(context.resources)
+ is DateTimeAgo -> textRaw.format(context)
else -> null
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaCompactListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaCompactListModel.kt
index d7c122492..c4145c330 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaCompactListModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaCompactListModel.kt
@@ -12,4 +12,5 @@ data class MangaCompactListModel(
override val counter: Int,
override val progress: ReadingProgress?,
override val isFavorite: Boolean,
+ override val isSaved: Boolean,
) : MangaListModel()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaDetailedListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaDetailedListModel.kt
index ee83aa9b8..53bfaf7fc 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaDetailedListModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaDetailedListModel.kt
@@ -13,5 +13,6 @@ data class MangaDetailedListModel(
override val counter: Int,
override val progress: ReadingProgress?,
override val isFavorite: Boolean,
+ override val isSaved: Boolean,
val tags: List,
) : MangaListModel()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt
index 52007c3b5..26f6e99a8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaGridModel.kt
@@ -11,4 +11,5 @@ data class MangaGridModel(
override val counter: Int,
override val progress: ReadingProgress?,
override val isFavorite: Boolean,
+ override val isSaved: Boolean,
) : MangaListModel()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt
index 4afb11370..06c804898 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/model/MangaListModel.kt
@@ -14,6 +14,7 @@ sealed class MangaListModel : ListModel {
abstract val coverUrl: String?
abstract val counter: Int
abstract val isFavorite: Boolean
+ abstract val isSaved: Boolean
abstract val progress: ReadingProgress?
val source: MangaSource
@@ -27,7 +28,9 @@ sealed class MangaListModel : ListModel {
previousState !is MangaListModel || previousState.manga != manga -> null
previousState.progress != progress -> PAYLOAD_PROGRESS_CHANGED
- previousState.isFavorite != isFavorite || previousState.counter != counter -> PAYLOAD_ANYTHING_CHANGED
+ previousState.isFavorite != isFavorite ||
+ previousState.isSaved != isSaved ||
+ previousState.counter != counter -> PAYLOAD_ANYTHING_CHANGED
else -> null
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt
index 2010c045c..1ba64c264 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewFragment.kt
@@ -21,6 +21,7 @@ import coil3.util.CoilUtils
import com.google.android.material.chip.Chip
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.BaseFragment
import org.koitharu.kotatsu.core.ui.image.CoverSizeResolver
import org.koitharu.kotatsu.core.ui.widgets.ChipsView
@@ -30,17 +31,12 @@ import org.koitharu.kotatsu.core.util.ext.drawable
import org.koitharu.kotatsu.core.util.ext.enqueueWith
import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.observe
-import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.core.util.ext.textAndVisible
import org.koitharu.kotatsu.databinding.FragmentPreviewBinding
-import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
-import org.koitharu.kotatsu.image.ui.ImageActivity
import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaTag
import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty
-import org.koitharu.kotatsu.reader.ui.ReaderActivity
import org.koitharu.kotatsu.search.ui.MangaListActivity
import javax.inject.Inject
@@ -77,33 +73,18 @@ class PreviewFragment : BaseFragment(), View.OnClickList
val manga = viewModel.manga.value
when (v.id) {
R.id.button_close -> closeSelf()
- R.id.button_open -> startActivity(
- DetailsActivity.newIntent(v.context, manga),
- )
-
- R.id.button_read -> {
- startActivity(
- ReaderActivity.IntentBuilder(v.context)
- .manga(manga)
- .build(),
- )
- }
+ R.id.button_open -> router.openDetails(manga)
+ R.id.button_read -> router.openReader(manga)
- R.id.textView_author -> startActivity(
- MangaListActivity.newIntent(
- context = v.context,
- source = manga.source,
- filter = MangaListFilter(query = manga.author),
- ),
+ R.id.textView_author -> router.openSearch(
+ source = manga.source,
+ query = manga.author ?: return,
)
- R.id.imageView_cover -> startActivity(
- ImageActivity.newIntent(
- v.context,
- manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } ?: return,
- manga.source,
- ),
- scaleUpActivityOptionsOf(v),
+ R.id.imageView_cover -> router.openImage(
+ url = manga.largeCoverUrl.ifNullOrEmpty { manga.coverUrl } ?: return,
+ source = manga.source,
+ anchor = v,
)
}
}
@@ -114,7 +95,7 @@ class PreviewFragment : BaseFragment(), View.OnClickList
val tag = data as? MangaTag ?: return
val filter = (activity as? FilterCoordinator.Owner)?.filterCoordinator
if (filter == null) {
- startActivity(MangaListActivity.newIntent(chip.context, tag.source, MangaListFilter(tags = setOf(tag))))
+ router.openList(tag)
} else {
filter.toggleTag(tag, true)
closeSelf()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt
index 062c1892e..1dbb5cd4b 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/list/ui/preview/PreviewViewModel.kt
@@ -22,7 +22,7 @@ import kotlinx.coroutines.flow.transformLatest
import kotlinx.coroutines.plus
import org.koitharu.kotatsu.core.model.getPreferredBranch
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
-import org.koitharu.kotatsu.core.parser.MangaIntent
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.require
@@ -42,7 +42,7 @@ class PreviewViewModel @Inject constructor(
) : BaseViewModel() {
val manga = MutableStateFlow(
- savedStateHandle.require(MangaIntent.KEY_MANGA).manga,
+ savedStateHandle.require(AppRouter.KEY_MANGA).manga,
)
val footer = combine(
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt
index 6922f9683..c43a1a0a5 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/MangaIndex.kt
@@ -12,13 +12,15 @@ import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.model.MangaSource
import org.koitharu.kotatsu.core.model.isLocal
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
+import org.koitharu.kotatsu.parsers.model.ContentRating
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaState
import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.parsers.util.find
+import org.koitharu.kotatsu.parsers.model.RATING_UNKNOWN
import org.koitharu.kotatsu.parsers.util.json.getBooleanOrDefault
+import org.koitharu.kotatsu.parsers.util.json.getEnumValueOrNull
import org.koitharu.kotatsu.parsers.util.json.getFloatOrDefault
import org.koitharu.kotatsu.parsers.util.json.getIntOrDefault
import org.koitharu.kotatsu.parsers.util.json.getLongOrDefault
@@ -34,120 +36,120 @@ class MangaIndex(source: String?) {
fun setMangaInfo(manga: Manga) {
require(!manga.isLocal) { "Local manga information cannot be stored" }
- json.put("id", manga.id)
- json.put("title", manga.title)
- json.put("title_alt", manga.altTitle)
- json.put("url", manga.url)
- json.put("public_url", manga.publicUrl)
- json.put("author", manga.author)
- json.put("cover", manga.coverUrl)
- json.put("description", manga.description)
- json.put("rating", manga.rating)
- json.put("nsfw", manga.isNsfw)
- json.put("state", manga.state?.name)
- json.put("source", manga.source.name)
- json.put("cover_large", manga.largeCoverUrl)
+ json.put(KEY_ID, manga.id)
+ json.put(KEY_TITLE, manga.title)
+ json.put(KEY_TITLE_ALT, manga.altTitle)
+ json.put(KEY_URL, manga.url)
+ json.put(KEY_PUBLIC_URL, manga.publicUrl)
+ json.put(KEY_AUTHOR, manga.author)
+ json.put(KEY_COVER, manga.coverUrl)
+ json.put(KEY_DESCRIPTION, manga.description)
+ json.put(KEY_RATING, manga.rating)
+ json.put(KEY_CONTENT_RATING, manga.contentRating)
+ json.put(KEY_NSFW, manga.isNsfw) // for backward compatibility
+ json.put(KEY_STATE, manga.state?.name)
+ json.put(KEY_SOURCE, manga.source.name)
+ json.put(KEY_COVER_LARGE, manga.largeCoverUrl)
json.put(
- "tags",
+ KEY_TAGS,
JSONArray().also { a ->
for (tag in manga.tags) {
val jo = JSONObject()
- jo.put("key", tag.key)
- jo.put("title", tag.title)
+ jo.put(KEY_KEY, tag.key)
+ jo.put(KEY_TITLE, tag.title)
a.put(jo)
}
},
)
- if (!json.has("chapters")) {
- json.put("chapters", JSONObject())
+ if (!json.has(KEY_CHAPTERS)) {
+ json.put(KEY_CHAPTERS, JSONObject())
}
- json.put("app_id", BuildConfig.APPLICATION_ID)
- json.put("app_version", BuildConfig.VERSION_CODE)
+ json.put(KEY_APP_ID, BuildConfig.APPLICATION_ID)
+ json.put(KEY_APP_VERSION, BuildConfig.VERSION_CODE)
}
fun getMangaInfo(): Manga? = if (json.length() == 0) null else runCatching {
- val source = MangaSource(json.getString("source"))
+ val source = MangaSource(json.getString(KEY_SOURCE))
Manga(
- id = json.getLong("id"),
- title = json.getString("title"),
- altTitle = json.getStringOrNull("title_alt"),
- url = json.getString("url"),
- publicUrl = json.getStringOrNull("public_url").orEmpty(),
- author = json.getStringOrNull("author"),
- largeCoverUrl = json.getStringOrNull("cover_large"),
+ id = json.getLong(KEY_ID),
+ title = json.getString(KEY_TITLE),
+ altTitle = json.getStringOrNull(KEY_TITLE_ALT),
+ url = json.getString(KEY_URL),
+ publicUrl = json.getStringOrNull(KEY_PUBLIC_URL).orEmpty(),
+ author = json.getStringOrNull(KEY_AUTHOR),
+ largeCoverUrl = json.getStringOrNull(KEY_COVER_LARGE),
source = source,
- rating = json.getDouble("rating").toFloat(),
- isNsfw = json.getBooleanOrDefault("nsfw", false),
- coverUrl = json.getString("cover"),
- state = json.getStringOrNull("state")?.let { stateString ->
- MangaState.entries.find(stateString)
- },
- description = json.getStringOrNull("description"),
- tags = json.getJSONArray("tags").mapJSONToSet { x ->
+ rating = json.getFloatOrDefault(KEY_RATING, RATING_UNKNOWN),
+ contentRating = json.getEnumValueOrNull(KEY_CONTENT_RATING, ContentRating::class.java)
+ ?: if (json.getBooleanOrDefault(KEY_NSFW, false)) ContentRating.ADULT else null,
+ coverUrl = json.getStringOrNull(KEY_COVER),
+ state = json.getEnumValueOrNull(KEY_STATE, MangaState::class.java),
+ description = json.getStringOrNull(KEY_DESCRIPTION),
+ tags = json.getJSONArray(KEY_TAGS).mapJSONToSet { x ->
MangaTag(
- title = x.getString("title").toTitleCase(),
- key = x.getString("key"),
+ title = x.getString(KEY_TITLE).toTitleCase(),
+ key = x.getString(KEY_KEY),
source = source,
)
},
- chapters = getChapters(json.getJSONObject("chapters"), source),
+ chapters = getChapters(json.getJSONObject(KEY_CHAPTERS), source),
)
}.getOrNull()
- fun getCoverEntry(): String? = json.getStringOrNull("cover_entry")
+ fun getCoverEntry(): String? = json.getStringOrNull(KEY_COVER_ENTRY)
fun addChapter(chapter: IndexedValue, filename: String?) {
- val chapters = json.getJSONObject("chapters")
+ val chapters = json.getJSONObject(KEY_CHAPTERS)
if (!chapters.has(chapter.value.id.toString())) {
val jo = JSONObject()
- jo.put("number", chapter.value.number)
- jo.put("volume", chapter.value.volume)
- jo.put("url", chapter.value.url)
- jo.put("name", chapter.value.name)
- jo.put("uploadDate", chapter.value.uploadDate)
- jo.put("scanlator", chapter.value.scanlator)
- jo.put("branch", chapter.value.branch)
- jo.put("entries", "%08d_%04d\\d{4}".format(chapter.value.branch.hashCode(), chapter.index + 1))
- jo.put("file", filename)
+ jo.put(KEY_NUMBER, chapter.value.number)
+ jo.put(KEY_VOLUME, chapter.value.volume)
+ jo.put(KEY_URL, chapter.value.url)
+ jo.put(KEY_NAME, chapter.value.name)
+ jo.put(KEY_UPLOAD_DATE, chapter.value.uploadDate)
+ jo.put(KEY_SCANLATOR, chapter.value.scanlator)
+ jo.put(KEY_BRANCH, chapter.value.branch)
+ jo.put(KEY_ENTRIES, "%08d_%04d\\d{4}".format(chapter.value.branch.hashCode(), chapter.index + 1))
+ jo.put(KEY_FILE, filename)
chapters.put(chapter.value.id.toString(), jo)
}
}
fun removeChapter(id: Long): Boolean {
- return json.has("chapters") && json.getJSONObject("chapters").remove(id.toString()) != null
+ return json.has(KEY_CHAPTERS) && json.getJSONObject(KEY_CHAPTERS).remove(id.toString()) != null
}
fun getChapterFileName(chapterId: Long): String? {
- return json.optJSONObject("chapters")?.optJSONObject(chapterId.toString())?.getStringOrNull("file")
+ return json.optJSONObject(KEY_CHAPTERS)?.optJSONObject(chapterId.toString())?.getStringOrNull(KEY_FILE)
}
fun setCoverEntry(name: String) {
- json.put("cover_entry", name)
+ json.put(KEY_COVER_ENTRY, name)
}
fun getChapterNamesPattern(chapter: MangaChapter) = Regex(
- json.getJSONObject("chapters")
+ json.getJSONObject(KEY_CHAPTERS)
.getJSONObject(chapter.id.toString())
- .getString("entries"),
+ .getString(KEY_ENTRIES),
)
fun sortChaptersByName() {
- val jo = json.getJSONObject("chapters")
+ val jo = json.getJSONObject(KEY_CHAPTERS)
val list = ArrayList(jo.length())
jo.keys().forEach { id ->
val item = jo.getJSONObject(id)
- item.put("id", id)
+ item.put(KEY_ID, id)
list.add(item)
}
val comparator = org.koitharu.kotatsu.core.util.AlphanumComparator()
- list.sortWith(compareBy(comparator) { it.getString("name") })
+ list.sortWith(compareBy(comparator) { it.getString(KEY_NAME) })
val newJo = JSONObject()
list.forEachIndexed { i, obj ->
- obj.put("number", i + 1)
- val id = obj.remove("id") as String
+ obj.put(KEY_NUMBER, i + 1)
+ val id = obj.remove(KEY_ID) as String
newJo.put(id, obj)
}
- json.put("chapters", newJo)
+ json.put(KEY_CHAPTERS, newJo)
}
fun clear() {
@@ -171,13 +173,13 @@ class MangaIndex(source: String?) {
chapters.add(
MangaChapter(
id = k.toLong(),
- name = v.getString("name"),
- url = v.getString("url"),
- number = v.getFloatOrDefault("number", 0f),
- volume = v.getIntOrDefault("volume", 0),
- uploadDate = v.getLongOrDefault("uploadDate", 0L),
- scanlator = v.getStringOrNull("scanlator"),
- branch = v.getStringOrNull("branch"),
+ name = v.getString(KEY_NAME),
+ url = v.getString(KEY_URL),
+ number = v.getFloatOrDefault(KEY_NUMBER, 0f),
+ volume = v.getIntOrDefault(KEY_VOLUME, 0),
+ uploadDate = v.getLongOrDefault(KEY_UPLOAD_DATE, 0L),
+ scanlator = v.getStringOrNull(KEY_SCANLATOR),
+ branch = v.getStringOrNull(KEY_BRANCH),
source = source,
),
)
@@ -193,6 +195,35 @@ class MangaIndex(source: String?) {
companion object {
+ private const val KEY_ID = "id"
+ private const val KEY_TITLE = "title"
+ private const val KEY_TITLE_ALT = "title_alt"
+ private const val KEY_URL = "url"
+ private const val KEY_PUBLIC_URL = "public_url"
+ private const val KEY_AUTHOR = "author"
+ private const val KEY_COVER = "cover"
+ private const val KEY_DESCRIPTION = "description"
+ private const val KEY_RATING = "rating"
+ private const val KEY_CONTENT_RATING = "content_rating"
+ private const val KEY_NSFW = "nsfw"
+ private const val KEY_STATE = "state"
+ private const val KEY_SOURCE = "source"
+ private const val KEY_COVER_LARGE = "cover_large"
+ private const val KEY_TAGS = "tags"
+ private const val KEY_CHAPTERS = "chapters"
+ private const val KEY_NUMBER = "number"
+ private const val KEY_VOLUME = "volume"
+ private const val KEY_NAME = "name"
+ private const val KEY_UPLOAD_DATE = "uploadDate"
+ private const val KEY_SCANLATOR = "scanlator"
+ private const val KEY_BRANCH = "branch"
+ private const val KEY_ENTRIES = "entries"
+ private const val KEY_FILE = "file"
+ private const val KEY_COVER_ENTRY = "cover_entry"
+ private const val KEY_KEY = "key"
+ private const val KEY_APP_ID = "app_id"
+ private const val KEY_APP_VERSION = "app_version"
+
@Blocking
@WorkerThread
fun read(fileSystem: FileSystem, path: Path): MangaIndex? = runCatchingCancellable {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt
index 013aa9275..e9f7faf55 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/PagesCache.kt
@@ -15,6 +15,8 @@ import okio.sink
import okio.use
import org.koitharu.kotatsu.core.exceptions.NoDataReceivedException
import org.koitharu.kotatsu.core.util.FileSize
+import org.koitharu.kotatsu.core.util.MimeTypes
+import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.compressToPNG
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.subdir
@@ -59,7 +61,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}
}
- suspend fun put(url: String, source: Source, mimeType: String?): File = withContext(Dispatchers.IO) {
+ suspend fun put(url: String, source: Source, mimeType: MimeType?): File = withContext(Dispatchers.IO) {
val file = createBufferFile(url, mimeType)
try {
val bytes = file.sink(append = false).buffer().use {
@@ -78,7 +80,7 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
}
suspend fun put(url: String, bitmap: Bitmap): File = withContext(Dispatchers.IO) {
- val file = createBufferFile(url, "image/png")
+ val file = createBufferFile(url, MimeType("image/png"))
try {
bitmap.compressToPNG(file)
val cache = lruCache.get()
@@ -107,9 +109,8 @@ class PagesCache @Inject constructor(@ApplicationContext context: Context) {
it.printStackTraceDebug()
}.getOrDefault(SIZE_DEFAULT)
- private suspend fun createBufferFile(url: String, mimeType: String?): File {
- val ext = mimeType?.let { MimeTypeMap.getSingleton().getExtensionFromMimeType(it) }
- ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" }
+ private suspend fun createBufferFile(url: String, mimeType: MimeType?): File {
+ val ext = MimeTypes.getExtension(mimeType) ?: MimeTypeMap.getFileExtensionFromUrl(url).ifNullOrEmpty { "dat" }
val cacheDir = cacheDir.get()
val rootDir = checkNotNull(cacheDir.parentFile) { "Cannot get parent for ${cacheDir.absolutePath}" }
val name = UUID.randomUUID().toString() + "." + ext
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt
index d2425be9e..a81ca5aab 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/index/LocalMangaIndex.kt
@@ -73,6 +73,10 @@ class LocalMangaIndex @Inject constructor(
}.getOrNull()
}
+ suspend operator fun contains(mangaId: Long): Boolean {
+ return db.getLocalMangaIndexDao().findPath(mangaId) != null
+ }
+
suspend fun put(manga: LocalManga) = mutex.withLock {
db.withTransaction {
upsert(manga)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt
index 6941c2409..89b3d66f0 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/input/LocalMangaParser.kt
@@ -1,7 +1,6 @@
package org.koitharu.kotatsu.local.data.input
import android.net.Uri
-import android.webkit.MimeTypeMap
import androidx.core.net.toFile
import androidx.core.net.toUri
import kotlinx.coroutines.Dispatchers
@@ -18,8 +17,11 @@ import okio.openZip
import org.jetbrains.annotations.Blocking
import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.util.AlphanumComparator
+import org.koitharu.kotatsu.core.util.MimeTypes
import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP
+import org.koitharu.kotatsu.core.util.ext.isDirectory
import org.koitharu.kotatsu.core.util.ext.isFileUri
+import org.koitharu.kotatsu.core.util.ext.isImage
import org.koitharu.kotatsu.core.util.ext.isRegularFile
import org.koitharu.kotatsu.core.util.ext.isZipUri
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
@@ -54,139 +56,191 @@ class LocalMangaParser(private val uri: Uri) {
private val rootFile: File = File(uri.schemeSpecificPart)
suspend fun getManga(withDetails: Boolean): LocalManga = runInterruptible(Dispatchers.IO) {
- val (fileSystem, rootPath) = uri.resolveFsAndPath()
- val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
- val mangaInfo = index?.getMangaInfo()
- if (mangaInfo != null) {
- val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it } ?: fileSystem.findFirstImage(rootPath)
- mangaInfo.copy(
- source = LocalMangaSource,
- url = rootFile.toUri().toString(),
- coverUrl = coverEntry?.let { uri.child(it, resolve = true).toString() },
- largeCoverUrl = null,
- chapters = if (withDetails) {
- mangaInfo.chapters?.mapNotNull { c ->
- val path = index.getChapterFileName(c.id)?.toPath()
- if (path != null && !fileSystem.exists(rootPath / path)) {
- null
- } else {
- c.copy(
- url = path?.let {
- uri.child(it, resolve = false).toString()
- } ?: uri.toString(),
+ (uri.resolveFsAndPath()).use { (fileSystem, rootPath) ->
+ val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
+ val mangaInfo = index?.getMangaInfo()
+ if (mangaInfo != null) {
+ val coverEntry: Path? = index.getCoverEntry()?.let { rootPath / it }
+ mangaInfo.copy(
+ source = LocalMangaSource,
+ url = rootFile.toUri().toString(),
+ coverUrl = coverEntry?.let {
+ uri.child(it, resolve = true).toString()
+ } ?: fileSystem.findFirstImageUri(rootPath)?.toString(),
+ largeCoverUrl = null,
+ chapters = if (withDetails) {
+ mangaInfo.chapters?.mapNotNull { c ->
+ val path = index.getChapterFileName(c.id)?.toPath()
+ if (path != null && !fileSystem.exists(rootPath / path)) {
+ null
+ } else {
+ c.copy(
+ url = path?.let {
+ uri.child(it, resolve = false).toString()
+ } ?: uri.toString(),
+ source = LocalMangaSource,
+ )
+ }
+ }
+ } else {
+ null
+ },
+ )
+ } else {
+ val title = rootFile.name.fileNameToTitle()
+ Manga(
+ id = rootFile.absolutePath.longHashCode(),
+ title = title,
+ url = rootFile.toUri().toString(),
+ publicUrl = rootFile.toUri().toString(),
+ source = LocalMangaSource,
+ coverUrl = fileSystem.findFirstImageUri(rootPath)?.toString(),
+ chapters = if (withDetails) {
+ val chapters = fileSystem.listRecursively(rootPath)
+ .mapNotNullTo(HashSet()) { path ->
+ when {
+ !fileSystem.isRegularFile(path) -> null
+ path.isImage() -> path.parent
+ hasZipExtension(path.name) -> path
+ else -> null
+ }
+ }.sortedWith(compareBy(AlphanumComparator()) { x -> x.toString() })
+ chapters.mapIndexed { i, p ->
+ val s = if (p.root == rootPath.root) {
+ p.relativeTo(rootPath).toString()
+ } else {
+ p
+ }.toString().removePrefix(Path.DIRECTORY_SEPARATOR)
+ MangaChapter(
+ id = "$i$s".longHashCode(),
+ name = s.fileNameToTitle().ifEmpty { title },
+ number = 0f,
+ volume = 0,
source = LocalMangaSource,
+ uploadDate = 0L,
+ url = uri.child(p.relativeTo(rootPath), resolve = false).toString(),
+ scanlator = null,
+ branch = null,
)
}
- }
- } else {
- null
- },
- )
- } else {
- val title = rootFile.name.fileNameToTitle()
- val coverEntry = fileSystem.findFirstImage(rootPath)
- val mimeTypeMap = MimeTypeMap.getSingleton()
- Manga(
- id = rootFile.absolutePath.longHashCode(),
- title = title,
- url = rootFile.toUri().toString(),
- publicUrl = rootFile.toUri().toString(),
- source = LocalMangaSource,
- coverUrl = coverEntry?.let { uri.child(it, resolve = true).toString() },
- chapters = if (withDetails) {
- val chapters = fileSystem.listRecursively(rootPath)
- .mapNotNullTo(HashSet()) { path ->
- when {
- path == coverEntry -> null
- !fileSystem.isRegularFile(path) -> null
- mimeTypeMap.isImage(path) -> path.parent
- hasZipExtension(path.name) -> path
- else -> null
- }
- }.sortedWith(compareBy(AlphanumComparator()) { x -> x.toString() })
- chapters.mapIndexed { i, p ->
- val s = if (p.root == rootPath.root) {
- p.relativeTo(rootPath).toString()
- } else {
- p
- }.toString().removePrefix(Path.DIRECTORY_SEPARATOR)
- MangaChapter(
- id = "$i$s".longHashCode(),
- name = s.fileNameToTitle().ifEmpty { title },
- number = 0f,
- volume = 0,
- source = LocalMangaSource,
- uploadDate = 0L,
- url = uri.child(p.relativeTo(rootPath), resolve = false).toString(),
- scanlator = null,
- branch = null,
- )
- }
- } else {
- null
- },
- altTitle = null,
- rating = -1f,
- isNsfw = false,
- tags = setOf(),
- state = null,
- author = null,
- largeCoverUrl = null,
- description = null,
- )
- }.let { LocalManga(it, rootFile) }
+ } else {
+ null
+ },
+ altTitle = null,
+ rating = -1f,
+ contentRating = null,
+ tags = setOf(),
+ state = null,
+ author = null,
+ largeCoverUrl = null,
+ description = null,
+ )
+ }.let { LocalManga(it, rootFile) }
+ }
}
suspend fun getMangaInfo(): Manga? = runInterruptible(Dispatchers.IO) {
- val (fileSystem, rootPath) = uri.resolveFsAndPath()
- val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
- index?.getMangaInfo()
+ uri.resolveFsAndPath().use { (fileSystem, rootPath) ->
+ val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
+ index?.getMangaInfo()
+ }
}
suspend fun getPages(chapter: MangaChapter): List = runInterruptible(Dispatchers.IO) {
val chapterUri = chapter.url.toUri().resolve()
- val (fileSystem, rootPath) = chapterUri.resolveFsAndPath()
- val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
- val entries = fileSystem.listRecursively(rootPath)
- .filter { fileSystem.isRegularFile(it) }
- if (index != null) {
- val pattern = index.getChapterNamesPattern(chapter)
- entries.filter { x -> x.name.substringBefore('.').matches(pattern) }
- } else {
- val mimeTypeMap = MimeTypeMap.getSingleton()
- entries.filter { x ->
- mimeTypeMap.isImage(x) && x.parent == rootPath
- }
- }.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() })
- .map { x ->
- val entryUri = chapterUri.child(x, resolve = true).toString()
- MangaPage(
- id = entryUri.longHashCode(),
- url = entryUri,
- preview = null,
- source = LocalMangaSource,
- )
- }
+ chapterUri.resolveFsAndPath().use { (fileSystem, rootPath) ->
+ val index = MangaIndex.read(fileSystem, rootPath / ENTRY_NAME_INDEX)
+ val entries = fileSystem.listRecursively(rootPath)
+ .filter { fileSystem.isRegularFile(it) }
+ if (index != null) {
+ val pattern = index.getChapterNamesPattern(chapter)
+ entries.filter { x -> x.name.substringBefore('.').matches(pattern) }
+ } else {
+ entries.filter { x -> x.isImage() && x.parent == rootPath }
+ }.toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() })
+ .map { x ->
+ val entryUri = chapterUri.child(x, resolve = true).toString()
+ MangaPage(
+ id = entryUri.longHashCode(),
+ url = entryUri,
+ preview = null,
+ source = LocalMangaSource,
+ )
+ }
+ }
}
private fun Uri.child(path: Path, resolve: Boolean): Uri {
+ val file = fileFromPath()
val builder = buildUpon()
- if (isZipUri() || !resolve) {
+ val isZip = isZipUri() || file.isZipArchive
+ if (isZip) {
+ builder.scheme(URI_SCHEME_ZIP)
+ }
+ if (isZip || !resolve) {
builder.fragment(path.toString().removePrefix(Path.DIRECTORY_SEPARATOR))
} else {
- val file = toFile()
- if (file.isZipArchive) {
- builder.fragment(path.toString().removePrefix(Path.DIRECTORY_SEPARATOR))
- builder.scheme(URI_SCHEME_ZIP)
- } else {
- builder.appendEncodedPath(path.relativeTo(file.toOkioPath()).toString())
- }
+ builder.appendEncodedPath(path.relativeTo(file.toOkioPath()).toString())
}
return builder.build()
}
+ private fun FileSystem.findFirstImageUri(
+ rootPath: Path,
+ recursive: Boolean = false
+ ): Uri? = runCatchingCancellable {
+ val list = list(rootPath)
+ for (file in list.sortedWith(compareBy(AlphanumComparator()) { x -> x.name })) {
+ if (isRegularFile(file)) {
+ if (file.isImage()) {
+ return@runCatchingCancellable uri.child(file, resolve = true)
+ }
+ if (recursive && file.isZip()) {
+ openZip(file).use { zipFs ->
+ zipFs.findFirstImageUri(Path.DIRECTORY_SEPARATOR.toPath())?.let { subUri ->
+ val subPath = subUri.path.orEmpty().removePrefix(uri.path.orEmpty())
+ .replace(REGEX_PARENT_PATH_PREFIX, "")
+ return@runCatchingCancellable uri.child(file, resolve = true)
+ .child(subPath.toPath(), resolve = false)
+ }
+ }
+ }
+ } else if (recursive && isDirectory(file)) {
+ findFirstImageUri(file, true)?.let {
+ return@runCatchingCancellable it
+ }
+ }
+ }
+ if (recursive) {
+ null
+ } else {
+ findFirstImageUri(rootPath, recursive = true)
+ }
+ }.onFailure { e ->
+ e.printStackTraceDebug()
+ }.getOrNull()
+
+ private class FsAndPath(
+ val fileSystem: FileSystem,
+ val path: Path,
+ private val isCloseable: Boolean,
+ ) : AutoCloseable {
+
+ override fun close() {
+ if (isCloseable) {
+ fileSystem.close()
+ }
+ }
+
+ operator fun component1() = fileSystem
+
+ operator fun component2() = path
+ }
+
companion object {
+ private val REGEX_PARENT_PATH_PREFIX = Regex("^(/\\.\\.)+")
+
@Blocking
fun getOrNull(file: File): LocalMangaParser? = if ((file.isDirectory || file.isZipArchive) && file.canRead()) {
LocalMangaParser(file)
@@ -207,28 +261,9 @@ class LocalMangaParser(private val uri: Uri) {
}
}.flowOn(Dispatchers.Default).firstOrNull()
- private fun FileSystem.findFirstImage(rootPath: Path) = findFirstImageImpl(rootPath, false)
- ?: findFirstImageImpl(rootPath, true)
+ private fun Path.isImage(): Boolean = MimeTypes.getMimeTypeFromExtension(name)?.isImage == true
- private fun FileSystem.findFirstImageImpl(
- rootPath: Path,
- recursive: Boolean
- ): Path? = runCatchingCancellable {
- val mimeTypeMap = MimeTypeMap.getSingleton()
- if (recursive) {
- listRecursively(rootPath)
- } else {
- list(rootPath).asSequence()
- }.filter { isRegularFile(it) && mimeTypeMap.isImage(it) }
- .toListSorted(compareBy(AlphanumComparator()) { x -> x.toString() })
- .firstOrNull()
- }.onFailure { e ->
- e.printStackTraceDebug()
- }.getOrNull()
-
- private fun MimeTypeMap.isImage(path: Path): Boolean =
- getMimeTypeFromExtension(path.name.substringAfterLast('.'))
- ?.startsWith("image/") == true
+ private fun Path.isZip(): Boolean = hasZipExtension(name)
private fun Uri.resolve(): Uri = if (isFileUri()) {
val file = toFile()
@@ -243,21 +278,28 @@ class LocalMangaParser(private val uri: Uri) {
this
}
+ private fun Uri.fileFromPath(): File = File(requireNotNull(path) { "Uri path is null: $this" })
+
@Blocking
- private fun Uri.resolveFsAndPath(): Pair {
+ private fun Uri.resolveFsAndPath(): FsAndPath {
val resolved = resolve()
return when {
- resolved.isZipUri() -> {
- FileSystem.SYSTEM.openZip(resolved.schemeSpecificPart.toPath()) to resolved.fragment.orEmpty()
- .toRootedPath()
- }
+ resolved.isZipUri() -> FsAndPath(
+ FileSystem.SYSTEM.openZip(resolved.schemeSpecificPart.toPath()),
+ resolved.fragment.orEmpty().toRootedPath(),
+ isCloseable = true,
+ )
isFileUri() -> {
val file = toFile()
if (file.isZipArchive) {
- FileSystem.SYSTEM.openZip(schemeSpecificPart.toPath()) to fragment.orEmpty().toRootedPath()
+ FsAndPath(
+ FileSystem.SYSTEM.openZip(schemeSpecificPart.toPath()),
+ fragment.orEmpty().toRootedPath(),
+ isCloseable = true,
+ )
} else {
- FileSystem.SYSTEM to file.toOkioPath()
+ FsAndPath(FileSystem.SYSTEM, file.toOkioPath(), isCloseable = false)
}
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt
index 3a58bd38f..6fabb761f 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaDirOutput.kt
@@ -8,6 +8,8 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.model.isLocal
+import org.koitharu.kotatsu.core.util.MimeTypes
+import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.takeIfReadable
import org.koitharu.kotatsu.core.util.ext.toFileNameSafe
@@ -35,10 +37,10 @@ class LocalMangaDirOutput(
override suspend fun mergeWithExisting() = Unit
- override suspend fun addCover(file: File, ext: String) = mutex.withLock {
+ override suspend fun addCover(file: File, type: MimeType?) = mutex.withLock {
val name = buildString {
append("cover")
- if (ext.isNotEmpty() && ext.length <= 4) {
+ MimeTypes.getExtension(type)?.let { ext ->
append('.')
append(ext)
}
@@ -50,14 +52,14 @@ class LocalMangaDirOutput(
flushIndex()
}
- override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) =
+ override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) =
mutex.withLock {
val output = chaptersOutput.getOrPut(chapter.value) {
ZipOutput(File(rootFile, chapterFileName(chapter) + SUFFIX_TMP))
}
val name = buildString {
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
- if (ext.isNotEmpty() && ext.length <= 4) {
+ MimeTypes.getExtension(type)?.let { ext ->
append('.')
append(ext)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt
index f8ffd8362..6b6672e8a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaOutput.kt
@@ -6,6 +6,7 @@ import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import okio.Closeable
import org.koitharu.kotatsu.core.prefs.DownloadFormat
+import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toFileNameSafe
import org.koitharu.kotatsu.local.data.input.LocalMangaParser
@@ -20,9 +21,9 @@ sealed class LocalMangaOutput(
abstract suspend fun mergeWithExisting()
- abstract suspend fun addCover(file: File, ext: String)
+ abstract suspend fun addCover(file: File, type: MimeType?)
- abstract suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String)
+ abstract suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?)
abstract suspend fun flushChapter(chapter: MangaChapter): Boolean
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt
index 22357e941..3d6767b9a 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/data/output/LocalMangaZipOutput.kt
@@ -7,6 +7,8 @@ import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.internal.closeQuietly
import org.koitharu.kotatsu.core.model.isLocal
+import org.koitharu.kotatsu.core.util.MimeTypes
+import org.koitharu.kotatsu.core.util.ext.MimeType
import org.koitharu.kotatsu.core.util.ext.deleteAwait
import org.koitharu.kotatsu.core.util.ext.readText
import org.koitharu.kotatsu.core.zip.ZipOutput
@@ -39,10 +41,10 @@ class LocalMangaZipOutput(
}
}
- override suspend fun addCover(file: File, ext: String) = mutex.withLock {
+ override suspend fun addCover(file: File, type: MimeType?) = mutex.withLock {
val name = buildString {
append(FILENAME_PATTERN.format(0, 0, 0))
- if (ext.isNotEmpty() && ext.length <= 4) {
+ MimeTypes.getExtension(type)?.let { ext ->
append('.')
append(ext)
}
@@ -53,11 +55,11 @@ class LocalMangaZipOutput(
index.setCoverEntry(name)
}
- override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, ext: String) =
+ override suspend fun addPage(chapter: IndexedValue, file: File, pageNumber: Int, type: MimeType?) =
mutex.withLock {
val name = buildString {
append(FILENAME_PATTERN.format(chapter.value.branch.hashCode(), chapter.index + 1, pageNumber))
- if (ext.isNotEmpty() && ext.length <= 4) {
+ MimeTypes.getExtension(type)?.let { ext ->
append('.')
append(ext)
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt
index fdc21e1d8..08b85061e 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportDialogFragment.kt
@@ -7,10 +7,10 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts
-import androidx.fragment.app.FragmentManager
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import org.koitharu.kotatsu.R
+import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.databinding.DialogImportBinding
@@ -26,7 +26,7 @@ class ImportDialogFragment : AlertDialogFragment(), View.On
private val importFileCall = registerForActivityResult(ActivityResultContracts.OpenMultipleDocuments()) {
startImport(it)
}
- private val importDirCall = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) {
+ private val importDirCall = OpenDocumentTreeHelper(this) {
startImport(listOfNotNull(it))
}
@@ -74,11 +74,4 @@ class ImportDialogFragment : AlertDialogFragment(), View.On
Toast.makeText(ctx, msg, Toast.LENGTH_LONG).show()
dismiss()
}
-
- companion object {
-
- private const val TAG = "ImportDialogFragment"
-
- fun show(fm: FragmentManager) = ImportDialogFragment().show(fm, TAG)
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt
index e60511cb5..b19c9386d 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/ImportService.kt
@@ -18,6 +18,7 @@ import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.runBlocking
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.ErrorReporterReceiver
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.CoroutineIntentService
import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission
import org.koitharu.kotatsu.core.util.ext.getDisplayMessage
@@ -25,7 +26,6 @@ import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra
import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug
import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull
import org.koitharu.kotatsu.core.util.ext.toUriOrNull
-import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.local.data.importer.SingleMangaImporter
import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
@@ -113,7 +113,7 @@ class ImportService : CoroutineIntentService() {
).toBitmapOrNull(),
)
notification.setSubText(manga.title)
- val intent = DetailsActivity.newIntent(applicationContext, manga)
+ val intent = AppRouter.detailsIntent(applicationContext, manga)
notification.setContentIntent(
PendingIntentCompat.getActivity(
applicationContext,
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt
index af638e699..d781020d6 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListFragment.kt
@@ -16,6 +16,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.snackbar.Snackbar
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.model.LocalMangaSource
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.ui.list.ListSelectionController
import org.koitharu.kotatsu.core.ui.widgets.TipView
import org.koitharu.kotatsu.core.util.ShareHelper
@@ -25,12 +26,10 @@ import org.koitharu.kotatsu.core.util.ext.tryLaunch
import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.FragmentListBinding
import org.koitharu.kotatsu.filter.ui.FilterCoordinator
-import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
import org.koitharu.kotatsu.list.ui.MangaListFragment
import org.koitharu.kotatsu.remotelist.ui.MangaSearchMenuProvider
import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment
import org.koitharu.kotatsu.settings.storage.RequestStorageManagerPermissionContract
-import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner {
@@ -68,11 +67,11 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner {
}
override fun onEmptyActionClick() {
- ImportDialogFragment.show(getChildFragmentManager())
+ router.showImportDialog()
}
override fun onFilterClick(view: View?) {
- FilterSheetFragment.show(getChildFragmentManager())
+ router.showFilterSheet()
}
override fun onPrimaryButtonClick(tipView: TipView) {
@@ -82,7 +81,7 @@ class LocalListFragment : MangaListFragment(), FilterCoordinator.Owner {
}
override fun onSecondaryButtonClick(tipView: TipView) {
- startActivity(MangaDirectoriesActivity.newIntent(tipView.context))
+ router.openDirectoriesSettings()
}
override fun onScrolledToEnd() = viewModel.loadNextPage()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt
index a1e3bd9b4..20136ac36 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListMenuProvider.kt
@@ -1,14 +1,12 @@
package org.koitharu.kotatsu.local.ui
-import android.content.Intent
import android.view.Menu
import android.view.MenuInflater
import android.view.MenuItem
import androidx.core.view.MenuProvider
import androidx.fragment.app.Fragment
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment
-import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity
+import org.koitharu.kotatsu.core.nav.router
class LocalListMenuProvider(
private val fragment: Fragment,
@@ -21,7 +19,7 @@ class LocalListMenuProvider(
override fun onPrepareMenu(menu: Menu) {
super.onPrepareMenu(menu)
- menu.findItem(R.id.action_filter)?.isVisible = FilterSheetFragment.isSupported(fragment)
+ menu.findItem(R.id.action_filter)?.isVisible = fragment.router.isFilterSupported()
}
override fun onMenuItemSelected(menuItem: MenuItem): Boolean {
@@ -32,14 +30,12 @@ class LocalListMenuProvider(
}
R.id.action_directories -> {
- fragment.context?.run {
- startActivity(Intent(this, MangaDirectoriesActivity::class.java))
- }
+ fragment.router.openDirectoriesSettings()
true
}
R.id.action_filter -> {
- FilterSheetFragment.show(fragment.childFragmentManager)
+ fragment.router.showFilterSheet()
true
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
index 5c60c1bf9..421a723e3 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/LocalListViewModel.kt
@@ -8,6 +8,7 @@ import kotlinx.coroutines.flow.SharedFlow
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
+import org.koitharu.kotatsu.core.prefs.ListMode
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
import org.koitharu.kotatsu.core.util.ext.toFileOrNull
@@ -25,6 +26,7 @@ import org.koitharu.kotatsu.local.data.LocalStorageChanges
import org.koitharu.kotatsu.local.data.LocalStorageManager
import org.koitharu.kotatsu.local.domain.DeleteLocalMangaUseCase
import org.koitharu.kotatsu.local.domain.model.LocalManga
+import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.remotelist.ui.RemoteListViewModel
import javax.inject.Inject
@@ -107,6 +109,12 @@ class LocalListViewModel @Inject constructor(
}
}
+ override suspend fun mapMangaList(
+ destination: MutableCollection,
+ manga: Collection,
+ mode: ListMode
+ ) = mangaListMapper.toListModelList(destination, manga, mode, MangaListMapper.NO_SAVED)
+
override fun createEmptyState(canResetFilter: Boolean): EmptyState = if (canResetFilter) {
super.createEmptyState(true)
} else {
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt
index 4afdb3ecb..9569350a8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoDialog.kt
@@ -7,13 +7,11 @@ import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.core.widget.TextViewCompat
-import androidx.fragment.app.FragmentManager
import androidx.fragment.app.viewModels
import com.google.android.material.dialog.MaterialAlertDialogBuilder
import dagger.hilt.android.AndroidEntryPoint
import kotlinx.coroutines.flow.combine
import org.koitharu.kotatsu.R
-import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
import org.koitharu.kotatsu.core.ui.AlertDialogFragment
import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView
import org.koitharu.kotatsu.core.util.FileSize
@@ -21,10 +19,7 @@ import org.koitharu.kotatsu.core.util.KotatsuColors
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
import org.koitharu.kotatsu.core.util.ext.setProgressIcon
-import org.koitharu.kotatsu.core.util.ext.showDistinct
-import org.koitharu.kotatsu.core.util.ext.withArgs
import org.koitharu.kotatsu.databinding.DialogLocalInfoBinding
-import org.koitharu.kotatsu.parsers.model.Manga
import com.google.android.material.R as materialR
@AndroidEntryPoint
@@ -108,16 +103,4 @@ class LocalInfoDialog : AlertDialogFragment(), View.OnCl
)
view.animateSegments(listOf(segment))
}
-
- companion object {
-
- const val ARG_MANGA = "manga"
- private const val TAG = "LocalInfoDialog"
-
- fun show(fm: FragmentManager, manga: Manga) {
- LocalInfoDialog().withArgs(1) {
- putParcelable(ARG_MANGA, ParcelableManga(manga))
- }.showDistinct(fm, TAG)
- }
- }
}
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt
index 648a8383c..1f1c78bd8 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/local/ui/info/LocalInfoViewModel.kt
@@ -6,6 +6,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga
+import org.koitharu.kotatsu.core.nav.AppRouter
import org.koitharu.kotatsu.core.ui.BaseViewModel
import org.koitharu.kotatsu.core.util.ext.MutableEventFlow
import org.koitharu.kotatsu.core.util.ext.call
@@ -25,7 +26,7 @@ class LocalInfoViewModel @Inject constructor(
private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase,
) : BaseViewModel() {
- private val manga = savedStateHandle.require(LocalInfoDialog.ARG_MANGA).manga
+ private val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga
val isCleaningUp = MutableStateFlow(false)
val onCleanedUp = MutableEventFlow>()
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt
index 5ca72a833..d2a1100eb 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/domain/CoverRestoreInterceptor.kt
@@ -32,17 +32,17 @@ class CoverRestoreInterceptor @Inject constructor(
val result = chain.proceed()
if (result is ErrorResult && result.throwable.shouldRestore()) {
request.extras[mangaKey]?.let {
- if (restoreManga(it)) {
- return chain.withRequest(request.newBuilder().build()).proceed()
+ return if (restoreManga(it)) {
+ chain.withRequest(request.newBuilder().build()).proceed()
} else {
- return result
+ result
}
}
request.extras[bookmarkKey]?.let {
- if (restoreBookmark(it)) {
- return chain.withRequest(request.newBuilder().build()).proceed()
+ return if (restoreBookmark(it)) {
+ chain.withRequest(request.newBuilder().build()).proceed()
} else {
- return result
+ result
}
}
}
@@ -66,7 +66,7 @@ class CoverRestoreInterceptor @Inject constructor(
}
private suspend fun restoreMangaImpl(manga: Manga): Boolean {
- if (dataRepository.findMangaById(manga.id) == null || manga.isLocal) {
+ if (dataRepository.findMangaById(manga.id, withChapters = false) == null || manga.isLocal) {
return false
}
val repo = repositoryFactory.create(manga.source)
diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt
index 87181e871..330e4a06c 100644
--- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt
+++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainActivity.kt
@@ -40,6 +40,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.koitharu.kotatsu.R
import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver
+import org.koitharu.kotatsu.core.nav.router
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.core.prefs.NavItem
import org.koitharu.kotatsu.core.ui.BaseActivity
@@ -49,29 +50,20 @@ import org.koitharu.kotatsu.core.ui.util.OptionsMenuBadgeHelper
import org.koitharu.kotatsu.core.ui.widgets.SlidingBottomNavigationView
import org.koitharu.kotatsu.core.util.ext.observe
import org.koitharu.kotatsu.core.util.ext.observeEvent
-import org.koitharu.kotatsu.core.util.ext.scaleUpActivityOptionsOf
import org.koitharu.kotatsu.databinding.ActivityMainBinding
import org.koitharu.kotatsu.details.service.MangaPrefetchService
-import org.koitharu.kotatsu.details.ui.DetailsActivity
import org.koitharu.kotatsu.favourites.ui.container.FavouritesContainerFragment
import org.koitharu.kotatsu.history.ui.HistoryListFragment
import org.koitharu.kotatsu.local.ui.LocalIndexUpdateService
import org.koitharu.kotatsu.local.ui.LocalStorageCleanupWorker
import org.koitharu.kotatsu.main.ui.owners.AppBarOwner
import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner
-import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet
import org.koitharu.kotatsu.parsers.model.Manga
-import org.koitharu.kotatsu.parsers.model.MangaListFilter
import org.koitharu.kotatsu.parsers.model.MangaSource
import org.koitharu.kotatsu.parsers.model.MangaTag
-import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder
-import org.koitharu.kotatsu.search.ui.MangaListActivity
-import org.koitharu.kotatsu.search.ui.multi.SearchActivity
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionListener
import org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionViewModel
-import org.koitharu.kotatsu.settings.SettingsActivity
-import org.koitharu.kotatsu.settings.about.AppUpdateActivity
import org.koitharu.kotatsu.settings.backup.PeriodicalBackupService
import javax.inject.Inject
import com.google.android.material.R as materialR
@@ -137,9 +129,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav
viewModel.isResumeEnabled.observe(this, this::onResumeEnabledChanged)
viewModel.feedCounter.observe(this, ::onFeedCounterChanged)
viewModel.appUpdate.observe(this, MenuInvalidator(this))
- viewModel.onFirstStart.observeEvent(this) {
- WelcomeSheet.show(supportFragmentManager)
- }
+ viewModel.onFirstStart.observeEvent(this) { router.showWelcomeSheet() }
viewModel.isBottomNavPinned.observe(this, ::setNavbarPinned)
searchSuggestionViewModel.isIncognitoModeEnabled.observe(this, this::onIncognitoModeChanged)
viewBinding.bottomNav?.addOnLayoutChangeListener(this)
@@ -188,7 +178,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav
}
R.id.action_settings -> {
- startActivity(SettingsActivity.newIntent(this))
+ router.openSettings()
true
}
@@ -198,7 +188,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav
}
R.id.action_app_update -> {
- startActivity(Intent(this, AppUpdateActivity::class.java))
+ router.openAppUpdate()
true
}
@@ -241,7 +231,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav
if (fragment == null) {
supportFragmentManager.commit {
setReorderingAllowed(true)
- add(R.id.container, SearchSuggestionFragment.newInstance(), TAG_SEARCH)
+ add(R.id.container, SearchSuggestionFragment(), TAG_SEARCH)
navigationDelegate.primaryFragment?.let {
setMaxLifecycle(it, Lifecycle.State.STARTED)
}
@@ -253,13 +243,13 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav
}
override fun onMangaClick(manga: Manga) {
- startActivity(DetailsActivity.newIntent(this, manga))
+ router.openDetails(manga)
}
override fun onQueryClick(query: String, submit: Boolean) {
viewBinding.searchView.query = query
if (submit && query.isNotEmpty()) {
- startActivity(SearchActivity.newIntent(this, query))
+ router.openSearch(query)
searchSuggestionViewModel.saveQuery(query)
viewBinding.searchView.post {
closeSearchCallback.handleOnBackPressed()
@@ -268,7 +258,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav
}
override fun onTagClick(tag: MangaTag) {
- startActivity(MangaListActivity.newIntent(this, tag.source, MangaListFilter(tags = setOf(tag))))
+ router.openList(tag)
}
override fun onQueryChanged(query: String) {
@@ -280,8 +270,7 @@ class MainActivity : BaseActivity