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

+ +
+ +**📌 Pull requests are welcome, if you want: See [CONTRIBUTING.md](https://github.com/KotatsuApp/Kotatsu/blob/devel/CONTRIBUTING.md) for the guidelines** ### License [![GNU GPLv3 Image](https://www.gnu.org/graphics/gplv3-127x51.png)](http://www.gnu.org/licenses/gpl-3.0.en.html) -You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications -to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & -install instructions. +
+ +You may copy, distribute and modify the software as long as you track changes/dates in source files. Any modifications to or software including (via compiler) GPL-licensed code must also be made available under the GPL along with build & install instructions. + +
### DMCA disclaimer -The developers of this application do not have any affiliation with the content available in the app. -It collects content from sources that are freely available through any web browser +
+ +The developers of this application do not have any affiliation with the content available in the app. It collects content from sources that are freely available through any web browser. + +
diff --git a/app/build.gradle b/app/build.gradle index 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(), AppBarOwner, BottomNav } override fun onSourceClick(source: MangaSource) { - val intent = MangaListActivity.newIntent(this, source, null) - startActivity(intent) + router.openList(source, null) } override fun onSupportActionModeStarted(mode: ActionMode) { @@ -302,10 +291,7 @@ class MainActivity : BaseActivity(), AppBarOwner, BottomNav private fun onOpenReader(manga: Manga) { val fab = viewBinding.fab ?: viewBinding.navRail?.headerView - val options = fab?.let { - scaleUpActivityOptionsOf(it) - } - startActivity(IntentBuilder(this).manga(manga).build(), options) + router.openReader(manga, fab) } private fun onFeedCounterChanged(counter: Int) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt index 611774e87..8d6c0df78 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/MainNavigationDelegate.kt @@ -240,4 +240,9 @@ class MainNavigationDelegate( fun onFragmentChanged(fragment: Fragment, fromUser: Boolean) } + + companion object { + + const val MAX_ITEM_COUNT = 6 + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt index 9516a8447..49867b411 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/protect/ProtectActivity.kt @@ -4,7 +4,6 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.text.Editable -import android.text.TextWatcher import android.view.KeyEvent import android.view.View import android.view.WindowManager @@ -21,6 +20,7 @@ import com.google.android.material.textfield.TextInputLayout import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.observe @@ -32,7 +32,7 @@ import com.google.android.material.R as materialR class ProtectActivity : BaseActivity(), TextView.OnEditorActionListener, - TextWatcher, + DefaultTextWatcher, View.OnClickListener { private val viewModel by viewModels() @@ -98,10 +98,6 @@ class ProtectActivity : } } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - override fun afterTextChanged(s: Editable?) { viewBinding.layoutPassword.error = null viewBinding.buttonNext.isEnabled = !s.isNullOrEmpty() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt index 50bed1ffc..48d0cc61f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/main/ui/welcome/WelcomeSheet.kt @@ -9,23 +9,21 @@ import android.view.ViewGroup import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isGone -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.chip.Chip import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.ui.widgets.ChipsView import org.koitharu.kotatsu.core.util.ext.getDisplayName import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.databinding.SheetWelcomeBinding import org.koitharu.kotatsu.filter.ui.model.FilterProperty import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment import java.util.Locale @AndroidEntryPoint @@ -82,7 +80,7 @@ class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipC override fun onActivityResult(result: Uri?) { if (result != null) { - RestoreDialogFragment.show(parentFragmentManager, result) + router.showBackupRestoreDialog(result) } } @@ -111,17 +109,4 @@ class WelcomeSheet : BaseAdaptiveSheet(), ChipsView.OnChipC }, ) } - - companion object { - - private const val TAG = "WelcomeSheet" - - fun show(fm: FragmentManager) = WelcomeSheet().showDistinct(fm, TAG) - - fun dismiss(fm: FragmentManager): Boolean { - val sheet = fm.findFragmentByTag(TAG) as? WelcomeSheet ?: return false - sheet.dismissAllowingStateLoss() - return true - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt index ff5359b3f..f891652f4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/domain/PageLoader.kt @@ -36,6 +36,7 @@ import org.koitharu.kotatsu.core.parser.CachingMangaRepository import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.RetainedLifecycleCoroutineScope import org.koitharu.kotatsu.core.util.ext.URI_SCHEME_ZIP import org.koitharu.kotatsu.core.util.ext.cancelChildrenAndJoin @@ -47,9 +48,9 @@ import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isNotEmpty import org.koitharu.kotatsu.core.util.ext.isPowerSaveMode import org.koitharu.kotatsu.core.util.ext.isZipUri -import org.koitharu.kotatsu.core.util.ext.mimeType import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.ramAvailable +import org.koitharu.kotatsu.core.util.ext.toMimeType import org.koitharu.kotatsu.core.util.ext.use import org.koitharu.kotatsu.core.util.ext.withProgress import org.koitharu.kotatsu.core.util.progress.ProgressDeferred @@ -57,7 +58,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadSlowdownDispatcher import org.koitharu.kotatsu.local.data.PagesCache import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.mimeType import org.koitharu.kotatsu.parsers.util.requireBody import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.reader.ui.pager.ReaderPage @@ -66,7 +66,6 @@ import java.util.LinkedList import java.util.concurrent.atomic.AtomicInteger import java.util.zip.ZipFile import javax.inject.Inject -import kotlin.concurrent.Volatile import kotlin.coroutines.AbstractCoroutineContextElement import kotlin.coroutines.CoroutineContext @@ -146,7 +145,7 @@ class PageLoader @Inject constructor( val entry = zip.getEntry(uri.fragment) context.ensureRamAtLeast(entry.size * 2) zip.getInputStream(entry).use { - BitmapDecoderCompat.decode(it, entry.mimeType) + BitmapDecoderCompat.decode(it, MimeTypes.getMimeTypeFromExtension(entry.name)) } } } @@ -253,7 +252,7 @@ class PageLoader @Inject constructor( val request = createPageRequest(pageUrl, page.source) imageProxyInterceptor.interceptPageRequest(request, okHttp).ensureSuccess().use { response -> response.requireBody().withProgress(progress).use { - cache.put(pageUrl, it.source(), response.mimeType) + cache.put(pageUrl, it.source(), it.contentType()?.toMimeType()) } }.toUri() } @@ -267,7 +266,7 @@ class PageLoader @Inject constructor( private fun Deferred.isValid(): Boolean { return getCompletionResultOrNull()?.map { uri -> uri.exists() && uri.isTargetNotEmpty() - }?.getOrDefault(false) ?: true + }?.getOrDefault(false) != false } private class InternalErrorHandler : AbstractCoroutineContextElement(CoroutineExceptionHandler), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt index 339c71038..1989d2703 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveContract.kt @@ -5,9 +5,9 @@ import android.content.Intent import android.os.Build import android.os.Environment import android.provider.DocumentsContract -import android.webkit.MimeTypeMap import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toUri +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.toUriOrNull import java.io.File @@ -15,8 +15,7 @@ class PageSaveContract : ActivityResultContracts.CreateDocument("image/*") { override fun createIntent(context: Context, input: String): Intent { val intent = super.createIntent(context, input.substringAfterLast(File.separatorChar)) - intent.type = MimeTypeMap.getSingleton() - .getMimeTypeFromExtension(input.substringAfterLast('.')) ?: "image/*" + intent.type = MimeTypes.getMimeTypeFromExtension(input)?.toString() ?: "image/*" if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { val defaultUri = input.toUriOrNull()?.run { path?.let { p -> diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt index a6b1d2d3e..d2b393a3f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/PageSaveHelper.kt @@ -1,13 +1,10 @@ package org.koitharu.kotatsu.reader.ui import android.content.Context -import android.graphics.BitmapFactory import android.net.Uri -import android.webkit.MimeTypeMap import androidx.activity.result.ActivityResultCallback import androidx.activity.result.ActivityResultCaller import androidx.activity.result.ActivityResultLauncher -import androidx.activity.result.contract.ActivityResultContracts import androidx.core.net.toFile import androidx.core.net.toUri import androidx.documentfile.provider.DocumentFile @@ -28,7 +25,10 @@ import okio.buffer import okio.openZip import okio.sink import okio.source +import org.koitharu.kotatsu.core.image.BitmapDecoderCompat +import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.util.MimeTypes import org.koitharu.kotatsu.core.util.ext.isFileUri import org.koitharu.kotatsu.core.util.ext.isZipUri import org.koitharu.kotatsu.core.util.ext.toFileNameSafe @@ -52,8 +52,7 @@ class PageSaveHelper @AssistedInject constructor( ) : ActivityResultCallback { private val savePageRequest = activityResultCaller.registerForActivityResult(PageSaveContract(), this) - private val pickDirectoryRequest = - activityResultCaller.registerForActivityResult(ActivityResultContracts.OpenDocumentTree(), this) + private val pickDirectoryRequest = OpenDocumentTreeHelper(activityResultCaller, this) private var continuation: CancellableContinuation? = null @@ -99,10 +98,10 @@ class PageSaveHelper @AssistedInject constructor( val pageUri = pageLoader.loadPage(task.page, force = false) val proposedName = task.getFileBaseName() val ext = getPageExtension(pageUrl, pageUri) - val mime = requireNotNull(MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext)) { + val mime = requireNotNull(MimeTypes.getMimeTypeFromExtension("_.$ext")) { "Unknown type of $proposedName" } - val destination = destinationDir.createFile(mime, proposedName.substringBeforeLast('.')) + val destination = destinationDir.createFile(mime.toString(), proposedName) copyImpl(pageUri, destination?.uri ?: throw IOException("Cannot create destination file")) result.add(destination.uri) } @@ -119,12 +118,7 @@ class PageSaveHelper @AssistedInject constructor( ) { "Invalid page url: $url" } var extension = name.substringAfterLast('.', "") if (extension.length !in 2..4) { - val mimeType = fileUri.toFileOrNull()?.let { file -> getImageMimeType(file) } - extension = if (mimeType != null) { - MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType) ?: EXTENSION_FALLBACK - } else { - EXTENSION_FALLBACK - } + extension = fileUri.toFileOrNull()?.let { file -> getImageExtension(file) } ?: EXTENSION_FALLBACK } return extension } @@ -155,8 +149,7 @@ class PageSaveHelper @AssistedInject constructor( if (proposedName == null) { return dir } else { - val ext = proposedName.substringAfterLast('.', "") - val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext) ?: return null + val mime = MimeTypes.getMimeTypeFromExtension(proposedName)?.toString() ?: return null return dir.createFile(mime, proposedName.substringBeforeLast('.')) } } @@ -179,12 +172,8 @@ class PageSaveHelper @AssistedInject constructor( } } - private suspend fun getImageMimeType(file: File): String? = runInterruptible(Dispatchers.IO) { - val options = BitmapFactory.Options().apply { - inJustDecodeBounds = true - } - BitmapFactory.decodeFile(file.path, options)?.recycle() - options.outMimeType + private suspend fun getImageExtension(file: File): String? = runInterruptible(Dispatchers.IO) { + MimeTypes.getExtension(BitmapDecoderCompat.probeMimeType(file)) } data class Task( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt index 4adbad36a..a2c11be7e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderActivity.kt @@ -1,8 +1,6 @@ package org.koitharu.kotatsu.reader.ui -import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle import android.transition.Fade import android.transition.Slide @@ -30,19 +28,17 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.launch import kotlinx.coroutines.withContext -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.core.exceptions.resolve.DialogErrorObserver -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ReaderControl import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.ui.BaseFullscreenActivity import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.ui.widgets.ZoomControl import org.koitharu.kotatsu.core.util.IdlingDetector -import org.koitharu.kotatsu.core.util.ShareHelper import org.koitharu.kotatsu.core.util.ext.hasGlobalPoint import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled import org.koitharu.kotatsu.core.util.ext.isRtl @@ -52,9 +48,7 @@ import org.koitharu.kotatsu.core.util.ext.postDelayed import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.core.util.ext.zipWithPrevious import org.koitharu.kotatsu.databinding.ActivityReaderBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.details.ui.pager.pages.PagesSavedObserver -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.reader.data.TapGridSettings import org.koitharu.kotatsu.reader.domain.TapGridArea @@ -115,7 +109,6 @@ class ReaderActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityReaderBinding.inflate(layoutInflater)) - screenOrientationHelper.init(settings.readerScreenOrientation) readerManager = ReaderManager(supportFragmentManager, viewBinding.container, settings) supportActionBar?.setDisplayHomeAsUpEnabled(true) touchHelper = TapGridDispatcher(this, this) @@ -127,6 +120,8 @@ class ReaderActivity : ReaderSliderListener(viewModel, this).attachToSlider(viewBinding.slider) insetsDelegate.interceptingWindowInsetsListener = this idlingDetector.bindToLifecycle(this) + viewBinding.buttonPrev.setOnClickListener(controlDelegate) + viewBinding.buttonNext.setOnClickListener(controlDelegate) viewModel.onError.observeEvent( this, @@ -150,25 +145,34 @@ class ReaderActivity : viewModel.content.observe(this) { onLoadingStateChanged(viewModel.isLoading.value) } + viewModel.readerControls.observe(this, ::onReaderControlsChanged) viewModel.isKeepScreenOnEnabled.observe(this, this::setKeepScreenOn) + viewModel.isInfoBarTransparent.observe(this) { viewBinding.infoBar.drawBackground = !it } viewModel.isInfoBarEnabled.observe(this, ::onReaderBarChanged) viewModel.isBookmarkAdded.observe(this, MenuInvalidator(this)) - viewModel.isPagesSheetEnabled.observe(this, MenuInvalidator(viewBinding.toolbarBottom)) + val bottomMenuInvalidator = MenuInvalidator(viewBinding.toolbarBottom) + viewModel.isPagesSheetEnabled.observe(this, bottomMenuInvalidator) + screenOrientationHelper.observeAutoOrientation().observe(this, bottomMenuInvalidator) viewModel.onShowToast.observeEvent(this) { msgId -> Snackbar.make(viewBinding.container, msgId, Snackbar.LENGTH_SHORT) .setAnchorView(viewBinding.appbarBottom) .show() } + viewModel.readerSettings.observe(this) { + viewBinding.infoBar.applyColorScheme(isBlackOnWhite = it.background.isLight(this)) + } viewModel.isZoomControlsEnabled.observe(this) { viewBinding.zoomControl.isVisible = it } - addMenuProvider(ReaderTopMenuProvider(this, viewModel)) - viewBinding.toolbarBottom.addMenuProvider(ReaderBottomMenuProvider(this, readerManager, viewModel)) + addMenuProvider(ReaderMenuTopProvider(viewModel)) + viewBinding.toolbarBottom.addMenuProvider( + ReaderMenuBottomProvider(this, readerManager, screenOrientationHelper, this, viewModel), + ) } override fun getParentActivityIntent(): Intent? { val manga = viewModel.getMangaOrNull() ?: return null - return DetailsActivity.newIntent(this, manga) + return AppRouter.detailsIntent(this, manga) } override fun onUserInteraction() { @@ -238,12 +242,12 @@ class ReaderActivity : rawX >= viewBinding.root.width - gestureInsets.right || rawY >= viewBinding.root.height - gestureInsets.bottom || viewBinding.appbarTop.hasGlobalPoint(rawX, rawY) || - viewBinding.appbarBottom?.hasGlobalPoint(rawX, rawY) == true + viewBinding.appbarBottom.hasGlobalPoint(rawX, rawY) == true ) { false } else { val touchables = window.peekDecorView()?.touchables - touchables?.none { it.hasGlobalPoint(rawX, rawY) } ?: true + touchables?.none { it.hasGlobalPoint(rawX, rawY) } != false } } @@ -298,6 +302,13 @@ class ReaderActivity : } } + private fun onReaderControlsChanged(controls: Set) = with(viewBinding) { + buttonPrev.isVisible = ReaderControl.PREV_CHAPTER in controls + buttonNext.isVisible = ReaderControl.NEXT_CHAPTER in controls + slider.isVisible = ReaderControl.SLIDER in controls + toolbarBottom.invalidateMenu() + } + private fun setUiIsVisible(isUiVisible: Boolean) { if (viewBinding.appbarTop.isVisible != isUiVisible) { if (isAnimationsEnabled) { @@ -305,14 +316,12 @@ class ReaderActivity : .setOrdering(TransitionSet.ORDERING_TOGETHER) .addTransition(Slide(Gravity.TOP).addTarget(viewBinding.appbarTop)) .addTransition(Fade().addTarget(viewBinding.infoBar)) - viewBinding.appbarBottom?.let { bottomBar -> - transition.addTransition(Slide(Gravity.BOTTOM).addTarget(bottomBar)) - } + .addTransition(Slide(Gravity.BOTTOM).addTarget(viewBinding.appbarBottom)) TransitionManager.beginDelayedTransition(viewBinding.root, transition) } val isFullscreen = settings.isReaderFullscreenEnabled viewBinding.appbarTop.isVisible = isUiVisible - viewBinding.appbarBottom?.isVisible = isUiVisible + viewBinding.appbarBottom.isVisible = isUiVisible viewBinding.infoBar.isGone = isUiVisible || (!viewModel.isInfoBarEnabled.value) viewBinding.infoBar.isTimeVisible = isFullscreen systemUiController.setSystemUiVisible(isUiVisible || !isFullscreen) @@ -327,7 +336,7 @@ class ReaderActivity : right = systemBars.right, left = systemBars.left, ) - viewBinding.appbarBottom?.updateLayoutParams { + viewBinding.appbarBottom.updateLayoutParams { bottomMargin = systemBars.bottom + topMargin rightMargin = systemBars.right + topMargin leftMargin = systemBars.left + topMargin @@ -353,11 +362,11 @@ class ReaderActivity : override fun openMenu() { viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) val currentMode = readerManager.currentMode ?: return - ReaderConfigSheet.show(supportFragmentManager, currentMode) + router.showReaderConfigSheet(currentMode) } override fun scrollBy(delta: Int, smooth: Boolean): Boolean { - return readerManager.currentReader?.scrollBy(delta, smooth) ?: false + return readerManager.currentReader?.scrollBy(delta, smooth) == true } override fun toggleUiVisibility() { @@ -383,71 +392,31 @@ class ReaderActivity : viewBinding.infoBar.update(uiState) if (uiState == null) { supportActionBar?.subtitle = null - viewBinding.slider.isVisible = false + viewBinding.layoutSlider.isVisible = false return } supportActionBar?.subtitle = when { uiState.incognito -> getString(R.string.incognito_mode) else -> uiState.chapterName } - if (previous?.chapterName != null && uiState.chapterName != previous.chapterName) { - if (!uiState.chapterName.isNullOrEmpty()) { - viewBinding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION) - } + if (uiState.chapterName != previous?.chapterName && !uiState.chapterName.isNullOrEmpty()) { + viewBinding.toastView.showTemporary(uiState.chapterName, TOAST_DURATION) } if (uiState.isSliderAvailable()) { viewBinding.slider.valueTo = uiState.totalPages.toFloat() - 1 viewBinding.slider.setValueRounded(uiState.currentPage.toFloat()) - viewBinding.slider.isVisible = true } else { - viewBinding.slider.isVisible = false + viewBinding.slider.valueTo = 1f + viewBinding.slider.value = 0f } - } - - class IntentBuilder(context: Context) { - - private val intent = Intent(context, ReaderActivity::class.java) - .setAction(ACTION_MANGA_READ) - - fun manga(manga: Manga) = apply { - intent.putExtra(MangaIntent.KEY_MANGA, ParcelableManga(manga)) - } - - fun mangaId(mangaId: Long) = apply { - intent.putExtra(MangaIntent.KEY_ID, mangaId) - } - - fun incognito(incognito: Boolean) = apply { - intent.putExtra(EXTRA_INCOGNITO, incognito) - } - - fun branch(branch: String?) = apply { - intent.putExtra(EXTRA_BRANCH, branch) - } - - fun state(state: ReaderState?) = apply { - intent.putExtra(EXTRA_STATE, state) - } - - fun bookmark(bookmark: Bookmark) = manga( - bookmark.manga, - ).state( - ReaderState( - chapterId = bookmark.chapterId, - page = bookmark.page, - scroll = bookmark.scroll, - ), - ) - - fun build() = intent + viewBinding.slider.isEnabled = uiState.isSliderAvailable() + viewBinding.buttonNext.isEnabled = uiState.hasNextChapter() + viewBinding.buttonPrev.isEnabled = uiState.hasPreviousChapter() + viewBinding.layoutSlider.isVisible = true } companion object { - const val ACTION_MANGA_READ = "${BuildConfig.APPLICATION_ID}.action.READ_MANGA" - const val EXTRA_STATE = "state" - const val EXTRA_BRANCH = "branch" - const val EXTRA_INCOGNITO = "incognito" - private const val TOAST_DURATION = 1500L + private const val TOAST_DURATION = 2000L } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderBottomMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderBottomMenuProvider.kt deleted file mode 100644 index ab1013374..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderBottomMenuProvider.kt +++ /dev/null @@ -1,50 +0,0 @@ -package org.koitharu.kotatsu.reader.ui - -import android.view.Menu -import android.view.MenuInflater -import android.view.MenuItem -import androidx.core.view.MenuProvider -import androidx.fragment.app.FragmentActivity -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.details.ui.pager.ChaptersPagesSheet -import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet - -class ReaderBottomMenuProvider( - private val activity: FragmentActivity, - private val readerManager: ReaderManager, - private val viewModel: ReaderViewModel, -) : MenuProvider { - - override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { - menuInflater.inflate(R.menu.opt_reader_bottom, menu) - onPrepareMenu(menu) // fix, not called in toolbar - } - - override fun onPrepareMenu(menu: Menu) { - val hasPages = viewModel.content.value.pages.isNotEmpty() - menu.findItem(R.id.action_pages_thumbs).run { - isVisible = hasPages - if (hasPages) { - setIcon(if (viewModel.isPagesSheetEnabled.value) R.drawable.ic_grid else R.drawable.ic_list) - } - } - } - - override fun onMenuItemSelected(menuItem: MenuItem): Boolean { - return when (menuItem.itemId) { - R.id.action_pages_thumbs -> { - ChaptersPagesSheet.show(activity.supportFragmentManager) - true - } - - R.id.action_options -> { - viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) - val currentMode = readerManager.currentMode ?: return false - ReaderConfigSheet.show(activity.supportFragmentManager, currentMode) - true - } - - else -> false - } - } -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt index 53421d319..2076a50b5 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderControlDelegate.kt @@ -2,6 +2,7 @@ package org.koitharu.kotatsu.reader.ui import android.content.res.Resources import android.view.KeyEvent +import android.view.View import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode @@ -14,10 +15,17 @@ class ReaderControlDelegate( private val settings: AppSettings, private val tapGridSettings: TapGridSettings, private val listener: OnInteractionListener, -) { +) : View.OnClickListener { private var minScrollDelta = resources.getDimensionPixelSize(R.dimen.reader_scroll_delta_min) + override fun onClick(v: View) { + when (v.id) { + R.id.button_prev -> listener.switchChapterBy(-1) + R.id.button_next -> listener.switchChapterBy(1) + } + } + fun onGridTouch(area: TapGridArea): Boolean { val action = tapGridSettings.getTapAction( area = area, @@ -63,7 +71,7 @@ class ReaderControlDelegate( KeyEvent.KEYCODE_SPACE, KeyEvent.KEYCODE_PAGE_DOWN, - -> { + -> { listener.switchPageBy(1) true } @@ -74,7 +82,7 @@ class ReaderControlDelegate( } KeyEvent.KEYCODE_PAGE_UP, - -> { + -> { listener.switchPageBy(-1) true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt index 34bca6de8..19195337e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderInfoBarView.kt @@ -5,6 +5,7 @@ import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.content.IntentFilter +import android.content.res.ColorStateList import android.graphics.Canvas import android.graphics.Color import android.graphics.Paint @@ -19,12 +20,11 @@ import android.view.WindowInsets import androidx.annotation.AttrRes import androidx.core.content.ContextCompat import androidx.core.content.withStyledAttributes -import androidx.core.graphics.ColorUtils -import androidx.core.graphics.withScale import androidx.core.view.ViewCompat import androidx.core.view.WindowInsetsCompat import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.util.ext.getThemeColor +import org.koitharu.kotatsu.core.util.ext.getThemeColorStateList +import org.koitharu.kotatsu.core.util.ext.isNightMode import org.koitharu.kotatsu.core.util.ext.measureDimension import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.parsers.util.format @@ -34,6 +34,8 @@ import java.time.format.DateTimeFormatter import java.time.format.FormatStyle import com.google.android.material.R as materialR +private const val ALPHA_TEXT = 200 +private const val ALPHA_BG = 180 class ReaderInfoBarView @JvmOverloads constructor( context: Context, @@ -41,7 +43,7 @@ class ReaderInfoBarView @JvmOverloads constructor( @AttrRes defStyleAttr: Int = 0, ) : View(context, attrs, defStyleAttr) { - private val paint = Paint(Paint.ANTI_ALIAS_FLAG) + private val paint = Paint(Paint.ANTI_ALIAS_FLAG or Paint.SUBPIXEL_TEXT_FLAG) private val textBounds = Rect() private val timeFormat = DateTimeFormatter.ofLocalizedTime(FormatStyle.SHORT) private val systemStateReceiver = SystemStateReceiver() @@ -52,16 +54,16 @@ class ReaderInfoBarView @JvmOverloads constructor( private val insetRightFallback: Int private val insetTopFallback: Int private val insetCornerFallback = getSystemUiDimensionOffset("rounded_corner_content_padding") - private val colorText = ColorUtils.setAlphaComponent( - context.getThemeColor(materialR.attr.colorOnSurface, Color.BLACK), - 200, - ) - private val colorOutline = ColorUtils.setAlphaComponent( - context.getThemeColor(materialR.attr.colorSurface, Color.WHITE), - 200, - ) + private var colorText = + (context.getThemeColorStateList(materialR.attr.colorOnSurface) + ?: ColorStateList.valueOf(Color.BLACK)).withAlpha(ALPHA_TEXT) + private var colorBackground = + (context.getThemeColorStateList(materialR.attr.colorSurface) + ?: ColorStateList.valueOf(Color.WHITE)).withAlpha(ALPHA_BG) private val batteryIcon = ContextCompat.getDrawable(context, R.drawable.ic_battery_outline) + private var currentTextColor: Int = Color.TRANSPARENT + private var currentBackgroundColor: Int = Color.TRANSPARENT private var timeText = timeFormat.format(LocalTime.now()) private var batteryText = "" private var text: String = "" @@ -73,6 +75,12 @@ class ReaderInfoBarView @JvmOverloads constructor( private val innerWidth get() = width - paddingLeft - paddingRight - insetLeft - insetRight + var drawBackground: Boolean = false + set(value) { + field = value + invalidate() + } + var isTimeVisible: Boolean = true set(value) { field = value @@ -90,6 +98,7 @@ class ReaderInfoBarView @JvmOverloads constructor( insetLeftFallback = if (isRtl) insetEnd else insetStart insetRightFallback = if (isRtl) insetStart else insetEnd insetTopFallback = minOf(insetLeftFallback, insetRightFallback) + batteryIcon?.setTintList(colorText) } override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { @@ -106,24 +115,30 @@ class ReaderInfoBarView @JvmOverloads constructor( override fun onDraw(canvas: Canvas) { super.onDraw(canvas) + if (drawBackground) { + canvas.drawColor(currentBackgroundColor) + } computeTextHeight() val h = innerHeight.toFloat() val ty = h / 2f + textBounds.height() / 2f - textBounds.bottom paint.textAlign = Paint.Align.LEFT - canvas.drawTextOutline( + paint.color = currentTextColor + paint.style = Paint.Style.FILL + canvas.drawText( text, (paddingLeft + insetLeft).toFloat(), paddingTop + insetTop + ty, + paint, ) if (isTimeVisible) { paint.textAlign = Paint.Align.RIGHT var endX = (width - paddingRight - insetRight).toFloat() - canvas.drawTextOutline(timeText, endX, paddingTop + insetTop + ty) + canvas.drawText(timeText, endX, paddingTop + insetTop + ty, paint) if (batteryText.isNotEmpty()) { paint.getTextBounds(timeText, 0, timeText.length, textBounds) endX -= textBounds.width() endX -= h * 0.6f - canvas.drawTextOutline(batteryText, endX, paddingTop + insetTop + ty) + canvas.drawText(batteryText, endX, paddingTop + insetTop + ty, paint) batteryIcon?.let { paint.getTextBounds(batteryText, 0, batteryText.length, textBounds) endX -= textBounds.width() @@ -134,7 +149,7 @@ class ReaderInfoBarView @JvmOverloads constructor( endX.toInt(), (iconCenter + h / 2).toInt(), ) - it.drawWithOutline(canvas) + it.draw(canvas) } } } @@ -169,6 +184,42 @@ class ReaderInfoBarView @JvmOverloads constructor( context.unregisterReceiver(systemStateReceiver) } + override fun verifyDrawable(who: Drawable): Boolean { + return who == batteryIcon || super.verifyDrawable(who) + } + + override fun jumpDrawablesToCurrentState() { + super.jumpDrawablesToCurrentState() + batteryIcon?.jumpToCurrentState() + } + + override fun onCreateDrawableState(extraSpace: Int): IntArray? { + val iconState = batteryIcon?.state ?: return super.onCreateDrawableState(extraSpace) + return mergeDrawableStates(super.onCreateDrawableState(extraSpace + iconState.size), iconState) + } + + override fun drawableStateChanged() { + currentTextColor = colorText.getColorForState(drawableState, colorText.defaultColor) + currentBackgroundColor = colorBackground.getColorForState(drawableState, colorBackground.defaultColor) + super.drawableStateChanged() + if (batteryIcon != null && batteryIcon.isStateful && batteryIcon.setState(drawableState)) { + invalidateDrawable(batteryIcon) + } + } + + fun applyColorScheme(isBlackOnWhite: Boolean) { + val isDarkTheme = resources.isNightMode + colorText = (context.getThemeColorStateList( + if (isBlackOnWhite != isDarkTheme) materialR.attr.colorOnSurface else materialR.attr.colorOnSurfaceInverse, + ) ?: ColorStateList.valueOf(if (isBlackOnWhite) Color.BLACK else Color.WHITE)).withAlpha(ALPHA_TEXT) + colorBackground = (context.getThemeColorStateList( + if (isBlackOnWhite != isDarkTheme) materialR.attr.colorSurface else materialR.attr.colorSurfaceInverse, + ) ?: ColorStateList.valueOf(if (isBlackOnWhite) Color.WHITE else Color.BLACK)).withAlpha(ALPHA_BG) + batteryIcon?.setTintList(colorText) + drawableStateChanged() + } + + @SuppressLint("StringFormatMatches") fun update(state: ReaderUiState?) { text = if (state != null) { context.getString( @@ -199,32 +250,6 @@ class ReaderInfoBarView @JvmOverloads constructor( return textBounds.height() } - private fun Canvas.drawTextOutline(text: String, x: Float, y: Float) { - paint.color = colorOutline - paint.style = Paint.Style.STROKE - drawText(text, x, y, paint) - paint.color = colorText - paint.style = Paint.Style.FILL - drawText(text, x, y, paint) - } - - private fun Drawable.drawWithOutline(canvas: Canvas) { - if (bounds.isEmpty) { - return - } - var requiredScale = (bounds.width() + paint.strokeWidth * 2f) / bounds.width().toFloat() - setTint(colorOutline) - canvas.withScale(requiredScale, requiredScale, bounds.exactCenterX(), bounds.exactCenterY()) { - draw(canvas) - } - requiredScale = 1f / requiredScale - canvas.withScale(requiredScale, requiredScale, bounds.exactCenterX(), bounds.exactCenterY()) { - draw(canvas) - } - setTint(colorText) - draw(canvas) - } - private fun updateCutoutInsets(insetsCompat: WindowInsetsCompat?) { insetLeft = insetLeftFallback insetRight = insetRightFallback diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuBottomProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuBottomProvider.kt new file mode 100644 index 000000000..ace7fa17d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuBottomProvider.kt @@ -0,0 +1,111 @@ +package org.koitharu.kotatsu.reader.ui + +import android.view.Menu +import android.view.MenuInflater +import android.view.MenuItem +import android.widget.Toast +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.prefs.ReaderControl +import org.koitharu.kotatsu.reader.ui.config.ReaderConfigSheet + +class ReaderMenuBottomProvider( + private val activity: FragmentActivity, + private val readerManager: ReaderManager, + private val screenOrientationHelper: ScreenOrientationHelper, + private val configCallback: ReaderConfigSheet.Callback, + private val viewModel: ReaderViewModel, +) : MenuProvider { + + override fun onCreateMenu(menu: Menu, menuInflater: MenuInflater) { + menuInflater.inflate(R.menu.opt_reader_bottom, menu) + onPrepareMenu(menu) // fix, not called in toolbar + } + + override fun onPrepareMenu(menu: Menu) { + val readerControls = viewModel.readerControls.value + val hasPages = viewModel.content.value.pages.isNotEmpty() + val isPagesSheetEnabled = hasPages && ReaderControl.PAGES_SHEET in readerControls + menu.findItem(R.id.action_pages_thumbs).run { + isVisible = isPagesSheetEnabled + if (isPagesSheetEnabled) { + setIcon(if (viewModel.isPagesSheetEnabled.value) R.drawable.ic_grid else R.drawable.ic_list) + } + } + menu.findItem(R.id.action_screen_rotation).run { + isVisible = ReaderControl.SCREEN_ROTATION in readerControls + when { + !isVisible -> Unit + !screenOrientationHelper.isAutoRotationEnabled -> { + setTitle(R.string.rotate_screen) + setIcon(R.drawable.ic_screen_rotation) + } + + else -> { + setTitle(R.string.lock_screen_rotation) + setIcon(R.drawable.ic_screen_rotation_lock) + } + } + } + menu.findItem(R.id.action_save_page)?.run { + isVisible = hasPages && ReaderControl.SAVE_PAGE in readerControls + } + } + + override fun onMenuItemSelected(menuItem: MenuItem): Boolean { + return when (menuItem.itemId) { + R.id.action_screen_rotation -> { + toggleScreenRotation() + true + } + + R.id.action_save_page -> { + configCallback.onSavePageClick() + true + } + + R.id.action_pages_thumbs -> { + activity.router.showChapterPagesSheet() + true + } + + R.id.action_options -> { + viewModel.saveCurrentState(readerManager.currentReader?.getCurrentState()) + val currentMode = readerManager.currentMode ?: return false + activity.router.showReaderConfigSheet(currentMode) + true + } + + R.id.action_bookmark -> { + if (viewModel.isBookmarkAdded.value) { + viewModel.removeBookmark() + } else { + viewModel.addBookmark() + } + true + } + + else -> false + } + } + + private fun toggleScreenRotation() = with(screenOrientationHelper) { + if (isAutoRotationEnabled) { + val newValue = !isLocked + isLocked = newValue + Toast.makeText( + activity, + if (newValue) { + R.string.screen_rotation_locked + } else { + R.string.screen_rotation_unlocked + }, + Toast.LENGTH_SHORT, + ).show() + } else { + isLandscape = !isLandscape + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderTopMenuProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuTopProvider.kt similarity index 90% rename from app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderTopMenuProvider.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuTopProvider.kt index e2fa85e0a..aa66de0eb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderTopMenuProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderMenuTopProvider.kt @@ -4,11 +4,9 @@ import android.view.Menu import android.view.MenuInflater import android.view.MenuItem import androidx.core.view.MenuProvider -import androidx.fragment.app.FragmentActivity import org.koitharu.kotatsu.R -class ReaderTopMenuProvider( - private val activity: FragmentActivity, +class ReaderMenuTopProvider( private val viewModel: ReaderViewModel, ) : MenuProvider { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt index a2acc8df7..6357c20f7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderToastView.kt @@ -1,16 +1,19 @@ package org.koitharu.kotatsu.reader.ui +import android.animation.Animator +import android.animation.AnimatorListenerAdapter import android.content.Context import android.util.AttributeSet -import android.view.Gravity -import android.view.ViewGroup +import android.view.ViewPropertyAnimator +import android.view.animation.AccelerateInterpolator +import android.view.animation.DecelerateInterpolator import androidx.annotation.StringRes +import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.transition.Fade -import androidx.transition.Slide -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet import com.google.android.material.textview.MaterialTextView +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.util.ext.getAnimationDuration +import org.koitharu.kotatsu.core.util.ext.isAnimationsEnabled class ReaderToastView @JvmOverloads constructor( context: Context, @@ -18,6 +21,8 @@ class ReaderToastView @JvmOverloads constructor( defStyleAttr: Int = 0, ) : MaterialTextView(context, attrs, defStyleAttr) { + private var currentAnimator: ViewPropertyAnimator? = null + private var hideRunnable = Runnable { hide() } @@ -25,8 +30,7 @@ class ReaderToastView @JvmOverloads constructor( fun show(message: CharSequence) { removeCallbacks(hideRunnable) text = message - setupTransition() - isVisible = true + showImpl() } fun show(@StringRes messageId: Int) { @@ -40,8 +44,7 @@ class ReaderToastView @JvmOverloads constructor( fun hide() { removeCallbacks(hideRunnable) - setupTransition() - isVisible = false + hideImpl() } override fun onDetachedFromWindow() { @@ -49,13 +52,41 @@ class ReaderToastView @JvmOverloads constructor( super.onDetachedFromWindow() } - private fun setupTransition() { - val parentView = parent as? ViewGroup ?: return - val transition = TransitionSet() - .setOrdering(TransitionSet.ORDERING_TOGETHER) - .addTarget(this) - .addTransition(Slide(Gravity.BOTTOM)) - .addTransition(Fade()) - TransitionManager.beginDelayedTransition(parentView, transition) + private fun showImpl() { + currentAnimator?.cancel() + clearAnimation() + if (!context.isAnimationsEnabled) { + currentAnimator = null + isVisible = true + return + } + alpha = 0f + isVisible = true + currentAnimator = animate() + .alpha(1f) + .setInterpolator(DecelerateInterpolator()) + .setDuration(context.getAnimationDuration(R.integer.config_shorterAnimTime)) + .setListener(null) + } + + private fun hideImpl() { + currentAnimator?.cancel() + clearAnimation() + if (!context.isAnimationsEnabled) { + currentAnimator = null + isGone = true + return + } + currentAnimator = animate() + .alpha(0f) + .setInterpolator(AccelerateInterpolator()) + .setDuration(context.getAnimationDuration(R.integer.config_shorterAnimTime)) + .setListener( + object : AnimatorListenerAdapter() { + override fun onAnimationEnd(animation: Animator) { + isGone = true + } + }, + ) } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt index dd08b07d6..bdce762ed 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ReaderViewModel.kt @@ -21,9 +21,7 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn -import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.flow.update import kotlinx.coroutines.plus @@ -31,9 +29,10 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.bookmarks.domain.Bookmark import org.koitharu.kotatsu.bookmarks.domain.BookmarksRepository import org.koitharu.kotatsu.core.model.getPreferredBranch +import org.koitharu.kotatsu.core.nav.MangaIntent +import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.parser.MangaDataRepository -import org.koitharu.kotatsu.core.parser.MangaIntent import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.prefs.observeAsFlow @@ -104,8 +103,8 @@ class ReaderViewModel @Inject constructor( private var stateChangeJob: Job? = null init { - selectedBranch.value = savedStateHandle.get(ReaderActivity.EXTRA_BRANCH) - readingState.value = savedStateHandle[ReaderActivity.EXTRA_STATE] + selectedBranch.value = savedStateHandle.get(ReaderIntent.EXTRA_BRANCH) + readingState.value = savedStateHandle[ReaderIntent.EXTRA_STATE] mangaDetails.value = intent.manga?.let { MangaDetails(it, null, null, false) } } @@ -114,7 +113,7 @@ class ReaderViewModel @Inject constructor( val onShowToast = MutableEventFlow() val uiState = MutableStateFlow(null) - val incognitoMode = if (savedStateHandle.get(ReaderActivity.EXTRA_INCOGNITO) == true) { + val incognitoMode = if (savedStateHandle.get(ReaderIntent.EXTRA_INCOGNITO) == true) { MutableStateFlow(true) } else { interactor.observeIncognitoMode(manga) @@ -137,6 +136,18 @@ class ReaderViewModel @Inject constructor( valueProducer = { isReaderBarEnabled }, ) + val readerControls = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_READER_CONTROLS, + valueProducer = { readerControls }, + ) + + val isInfoBarTransparent = settings.observeAsStateFlow( + scope = viewModelScope + Dispatchers.Default, + key = AppSettings.KEY_READER_BAR_TRANSPARENT, + valueProducer = { isReaderBarTransparent }, + ) + val isKeepScreenOnEnabled = settings.observeAsStateFlow( scope = viewModelScope + Dispatchers.Default, key = AppSettings.KEY_READER_SCREEN_ON, @@ -192,10 +203,6 @@ class ReaderViewModel @Inject constructor( init { loadImpl() - settings.observe() - .onEach { key -> - if (key == AppSettings.KEY_READER_SLIDER) notifyStateChanged() - }.launchIn(viewModelScope + Dispatchers.Default) launchJob(Dispatchers.Default) { val mangaId = manga.filterNotNull().first().id appShortcutManager.notifyMangaOpened(mangaId) @@ -230,7 +237,7 @@ class ReaderViewModel @Inject constructor( fun saveCurrentState(state: ReaderState? = null) { if (state != null) { readingState.value = state - savedStateHandle[ReaderActivity.EXTRA_STATE] = state + savedStateHandle[ReaderIntent.EXTRA_STATE] = state } if (incognitoMode.value) { return @@ -448,7 +455,6 @@ class ReaderViewModel @Inject constructor( chaptersTotal = m.chapters[chapter.branch].sizeOrZero(), totalPages = chaptersLoader.getPagesCount(chapter.id), currentPage = state.page, - isSliderEnabled = settings.isReaderSliderEnabled, percent = computePercent(state.chapterId, state.page), incognito = incognitoMode.value, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt index 546577b5a..6dc370090 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/ScreenOrientationHelper.kt @@ -13,10 +13,14 @@ import kotlinx.coroutines.flow.callbackFlow import kotlinx.coroutines.flow.conflate import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.onStart +import org.koitharu.kotatsu.core.prefs.AppSettings import javax.inject.Inject @ActivityScoped -class ScreenOrientationHelper @Inject constructor(private val activity: Activity) { +class ScreenOrientationHelper @Inject constructor( + private val activity: Activity, + settings: AppSettings, +) { val isAutoRotationEnabled: Boolean get() = Settings.System.getInt( @@ -45,10 +49,10 @@ class ScreenOrientationHelper @Inject constructor(private val activity: Activity } } - fun init(orientation: Int) { + init { if (activity.requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED) { // https://developer.android.com/reference/android/R.attr.html#screenOrientation - activity.requestedOrientation = orientation + activity.requestedOrientation = settings.readerScreenOrientation } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt index b2e132758..95ea5fa31 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigActivity.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.reader.ui.colorfilter -import android.content.Context -import android.content.Intent import android.content.res.Resources import android.graphics.Bitmap import android.os.Bundle @@ -23,8 +21,6 @@ import com.google.android.material.slider.LabelFormatter import com.google.android.material.slider.Slider import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.ext.decodeRegion import org.koitharu.kotatsu.core.util.ext.enqueueWith @@ -35,7 +31,6 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setChecked import org.koitharu.kotatsu.core.util.ext.setValueRounded import org.koitharu.kotatsu.databinding.ActivityColorFilterBinding -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaPage import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.parsers.util.nullIfEmpty @@ -132,8 +127,8 @@ class ColorFilterConfigActivity : private fun onColorFilterChanged(readerColorFilter: ReaderColorFilter?) { viewBinding.sliderBrightness.setValueRounded(readerColorFilter?.brightness ?: 0f) viewBinding.sliderContrast.setValueRounded(readerColorFilter?.contrast ?: 0f) - viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted ?: false, false) - viewBinding.switchGrayscale.setChecked(readerColorFilter?.isGrayscale ?: false, false) + viewBinding.switchInvert.setChecked(readerColorFilter?.isInverted == true, false) + viewBinding.switchGrayscale.setChecked(readerColorFilter?.isGrayscale == true, false) viewBinding.imageViewAfter.colorFilter = readerColorFilter?.toColorFilter() } @@ -169,15 +164,4 @@ class ColorFilterConfigActivity : return pattern.format(percent) } } - - companion object { - - const val EXTRA_PAGES = "pages" - const val EXTRA_MANGA = "manga_id" - - fun newIntent(context: Context, manga: Manga, page: MangaPage) = - Intent(context, ColorFilterConfigActivity::class.java) - .putExtra(EXTRA_MANGA, ParcelableManga(manga)) - .putExtra(EXTRA_PAGES, ParcelableMangaPage(page)) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt index 91cc435a6..dec4a3d13 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/colorfilter/ColorFilterConfigViewModel.kt @@ -6,6 +6,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaPage +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaDataRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -13,7 +14,6 @@ import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.require import org.koitharu.kotatsu.reader.domain.ReaderColorFilter -import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity.Companion.EXTRA_MANGA import javax.inject.Inject @HiltViewModel @@ -23,12 +23,12 @@ class ColorFilterConfigViewModel @Inject constructor( private val mangaDataRepository: MangaDataRepository, ) : BaseViewModel() { - private val manga = savedStateHandle.require(EXTRA_MANGA).manga + private val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga private var initialColorFilter: ReaderColorFilter? = null val colorFilter = MutableStateFlow(null) val onDismiss = MutableEventFlow() - val preview = savedStateHandle.require(ColorFilterConfigActivity.EXTRA_PAGES).page + val preview = savedStateHandle.require(AppRouter.KEY_PAGES).page val isChanged: Boolean get() = colorFilter.value != initialColorFilter diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt index 37da96afe..e7bf450d0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderConfigSheet.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import android.widget.CompoundButton import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.fragment.app.FragmentManager import androidx.fragment.app.activityViewModels import androidx.lifecycle.lifecycleScope import com.google.android.material.button.MaterialButtonToggleGroup @@ -19,6 +18,8 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderMode @@ -26,15 +27,11 @@ import org.koitharu.kotatsu.core.prefs.observeAsStateFlow import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.ext.findParentCallback import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetReaderConfigBinding import org.koitharu.kotatsu.reader.domain.PageLoader import org.koitharu.kotatsu.reader.ui.ReaderViewModel import org.koitharu.kotatsu.reader.ui.ScreenOrientationHelper -import org.koitharu.kotatsu.reader.ui.colorfilter.ColorFilterConfigActivity -import org.koitharu.kotatsu.settings.SettingsActivity import javax.inject.Inject @AndroidEntryPoint @@ -64,7 +61,7 @@ class ReaderConfigSheet : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - mode = arguments?.getInt(ARG_MODE) + mode = arguments?.getInt(AppRouter.KEY_READER_MODE) ?.let { ReaderMode.valueOf(it) } ?: ReaderMode.STANDARD imageServerDelegate = ImageServerDelegate( @@ -129,7 +126,7 @@ class ReaderConfigSheet : override fun onClick(v: View) { when (v.id) { R.id.button_settings -> { - startActivity(SettingsActivity.newReaderSettingsIntent(v.context)) + router.openReaderSettings() dismissAllowingStateLoss() } @@ -145,7 +142,7 @@ class ReaderConfigSheet : R.id.button_color_filter -> { val page = viewModel.getCurrentPage() ?: return val manga = viewModel.getMangaOrNull() ?: return - startActivity(ColorFilterConfigActivity.newIntent(v.context, manga, page)) + router.openColorFilterConfig(manga, page) } R.id.button_image_server -> viewLifecycleScope.launch { @@ -243,14 +240,4 @@ class ReaderConfigSheet : fun onSavePageClick() } - - companion object { - - private const val TAG = "ReaderConfigBottomSheet" - private const val ARG_MODE = "mode" - - fun show(fm: FragmentManager, mode: ReaderMode) = ReaderConfigSheet().withArgs(1) { - putInt(ARG_MODE, mode.id) - }.showDistinct(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt index f93822540..03ea3677d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/config/ReaderSettings.kt @@ -18,6 +18,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.core.model.ZoomMode import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ReaderBackground import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.util.ext.isLowRamDevice import org.koitharu.kotatsu.reader.domain.ReaderColorFilter @@ -34,6 +35,9 @@ class ReaderSettings( val zoomMode: ZoomMode get() = settings.zoomMode + val background: ReaderBackground + get() = settings.readerBackground + val colorFilter: ReaderColorFilter? get() = colorFilterFlow.value?.takeUnless { it.isEmpty } ?: settings.readerColorFilter @@ -51,8 +55,7 @@ class ReaderSettings( get() = settings.isPagesNumbersEnabled fun applyBackground(view: View) { - val bg = settings.readerBackground - view.background = bg.resolve(view.context) + view.background = background.resolve(view.context) } fun isPagesCropEnabled(isWebtoon: Boolean) = settings.isPagesCropEnabled( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt index d37907fc8..9a5fb85e4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/PageHolderDelegate.kt @@ -80,7 +80,7 @@ class PageHolderDelegate( fun showErrorDetails(url: String?) { val e = error ?: return - exceptionResolver.showDetails(e, url) + exceptionResolver.showErrorDetails(e, url) } fun onAttachedToWindow() { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt index f4de7734b..151a3ee0c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/reader/ui/pager/ReaderUiState.kt @@ -10,10 +10,11 @@ data class ReaderUiState( val totalPages: Int, val percent: Float, val incognito: Boolean, - private val isSliderEnabled: Boolean, ) { - fun isSliderAvailable(): Boolean { - return isSliderEnabled && totalPages > 1 && currentPage < totalPages - } + fun hasNextChapter(): Boolean = chapterNumber < chaptersTotal + + fun hasPreviousChapter(): Boolean = chapterNumber > 1 + + fun isSliderAvailable(): Boolean = totalPages > 1 && currentPage < totalPages } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt index 0caec63bb..66c70f98c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListFragment.kt @@ -12,8 +12,8 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.drop import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.ui.util.MenuInvalidator import org.koitharu.kotatsu.core.util.ext.addMenuProvider @@ -22,12 +22,9 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.FragmentListBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.filter.ui.FilterCoordinator -import org.koitharu.kotatsu.filter.ui.sheet.FilterSheetFragment import org.koitharu.kotatsu.list.ui.MangaListFragment import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.settings.SettingsActivity @AndroidEntryPoint class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { @@ -42,9 +39,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { addMenuProvider(RemoteListMenuProvider()) addMenuProvider(MangaSearchMenuProvider(filterCoordinator, viewModel)) viewModel.isRandomLoading.observe(viewLifecycleOwner, MenuInvalidator(requireActivity())) - viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { - startActivity(DetailsActivity.newIntent(binding.root.context, it)) - } + viewModel.onOpenManga.observeEvent(viewLifecycleOwner) { router.openDetails(it) } filterCoordinator.observe().distinctUntilChangedBy { it.listFilter.isEmpty() } .drop(1) .observe(viewLifecycleOwner) { @@ -66,7 +61,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { } override fun onFilterClick(view: View?) { - FilterSheetFragment.show(getChildFragmentManager()) + router.showFilterSheet() } override fun onEmptyActionClick() { @@ -86,13 +81,10 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { Snackbar.make(requireViewBinding().recyclerView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT) .show() } else { - startActivity( - BrowserActivity.newIntent( - requireContext(), - url, - viewModel.source, - viewModel.source.getTitle(requireContext()), - ), + router.openBrowser( + url = url, + source = viewModel.source, + title = viewModel.source.getTitle(requireContext()), ) } } @@ -105,7 +97,7 @@ class RemoteListFragment : MangaListFragment(), FilterCoordinator.Owner { override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_source_settings -> { - startActivity(SettingsActivity.newSourceSettingsIntent(requireContext(), viewModel.source)) + router.openSourceSettings(viewModel.source) true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt index 55f08c9b4..48b6c1062 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/remotelist/ui/RemoteListViewModel.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.core.model.MangaSource import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call import org.koitharu.kotatsu.core.util.ext.getCauseUrl @@ -36,6 +37,7 @@ import org.koitharu.kotatsu.list.ui.model.EmptyState import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter import org.koitharu.kotatsu.list.ui.model.LoadingState +import org.koitharu.kotatsu.list.ui.model.MangaListModel import org.koitharu.kotatsu.list.ui.model.toErrorFooter import org.koitharu.kotatsu.list.ui.model.toErrorState import org.koitharu.kotatsu.parsers.model.Manga @@ -50,7 +52,7 @@ open class RemoteListViewModel @Inject constructor( mangaRepositoryFactory: MangaRepository.Factory, final override val filterCoordinator: FilterCoordinator, settings: AppSettings, - mangaListMapper: MangaListMapper, + protected val mangaListMapper: MangaListMapper, downloadScheduler: DownloadWorker.Scheduler, private val exploreRepository: ExploreRepository, sourcesRepository: MangaSourcesRepository, @@ -85,7 +87,7 @@ open class RemoteListViewModel @Inject constructor( list == null -> add(LoadingState) list.isEmpty() -> add(createEmptyState(canResetFilter = filterCoordinator.isFilterApplied)) else -> { - mangaListMapper.toListModelList(this, list, mode) + mapMangaList(this, list, mode) when { error != null -> add(error.toErrorFooter()) hasNext -> add(LoadingFooter()) @@ -171,6 +173,12 @@ open class RemoteListViewModel @Inject constructor( protected open suspend fun onBuildList(list: MutableList) = Unit + protected open suspend fun mapMangaList( + destination: MutableCollection, + manga: Collection, + mode: ListMode + ) = mangaListMapper.toListModelList(destination, manga, mode, 0) + fun openRandom() { if (randomJob?.isActive == true) { return diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt index a10a912f4..acf887f92 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigActivity.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.scrobbling.common.ui.config -import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View @@ -15,6 +14,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.disposeImageRequest @@ -24,9 +24,7 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.showOrHide import org.koitharu.kotatsu.databinding.ActivityScrobblerConfigBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerUser import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblingInfo import org.koitharu.kotatsu.scrobbling.common.ui.config.adapter.ScrobblingMangaAdapter @@ -84,9 +82,7 @@ class ScrobblerConfigActivity : BaseActivity(), } override fun onItemClick(item: ScrobblingInfo, view: View) { - startActivity( - DetailsActivity.newIntent(this, item.mangaId), - ) + router.openDetails(item.mangaId) } override fun onClick(v: View) { @@ -133,16 +129,9 @@ class ScrobblerConfigActivity : BaseActivity(), } companion object { - - const val EXTRA_SERVICE_ID = "service" - const val HOST_SHIKIMORI_AUTH = "shikimori-auth" const val HOST_ANILIST_AUTH = "anilist-auth" const val HOST_MAL_AUTH = "mal-auth" const val HOST_KITSU_AUTH = "kitsu-auth" - - fun newIntent(context: Context, service: ScrobblerService) = - Intent(context, ScrobblerConfigActivity::class.java) - .putExtra(EXTRA_SERVICE_ID, service.id) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt index 1bf51ec23..ca5f614cc 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/config/ScrobblerConfigViewModel.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.plus import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call @@ -100,11 +100,11 @@ class ScrobblerConfigViewModel @Inject constructor( private fun getScrobblerService( savedStateHandle: SavedStateHandle, ): ScrobblerService { - val serviceId = savedStateHandle.get(ScrobblerConfigActivity.EXTRA_SERVICE_ID) ?: 0 + val serviceId = savedStateHandle.get(AppRouter.KEY_ID) ?: 0 if (serviceId != 0) { return ScrobblerService.entries.first { it.id == serviceId } } - val uri = savedStateHandle.require(BaseActivity.EXTRA_DATA) + val uri = savedStateHandle.require(AppRouter.KEY_DATA) return when (uri.host) { ScrobblerConfigActivity.HOST_SHIKIMORI_AUTH -> ScrobblerService.SHIKIMORI ScrobblerConfigActivity.HOST_ANILIST_AUTH -> ScrobblerService.ANILIST diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt index f3334a338..c4b319de2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorSheet.kt @@ -7,7 +7,6 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.widget.SearchView -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import androidx.recyclerview.widget.AsyncListDiffer import androidx.recyclerview.widget.RecyclerView.NO_ID @@ -17,8 +16,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet @@ -31,15 +29,12 @@ import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setProgressIcon import org.koitharu.kotatsu.core.util.ext.setTabsEnabled import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetScrobblingSelectorBinding import org.koitharu.kotatsu.list.ui.adapter.ListStateHolderListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.list.ui.model.LoadingFooter -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerManga -import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerMangaSelectionDecoration import org.koitharu.kotatsu.scrobbling.common.ui.selector.adapter.ScrobblerSelectorAdapter import javax.inject.Inject @@ -229,7 +224,7 @@ class ScrobblingSelectorSheet : private fun initTabs() { val entries = viewModel.availableScrobblers val tabs = requireViewBinding().tabs - val selectedId = arguments?.getInt(ARG_SCROBBLER, -1) ?: -1 + val selectedId = arguments?.getInt(AppRouter.KEY_ID, -1) ?: -1 tabs.removeAllTabs() tabs.clearOnTabSelectedListeners() tabs.addOnTabSelectedListener(this) @@ -244,18 +239,4 @@ class ScrobblingSelectorSheet : } } } - - companion object { - - private const val TAG = "ScrobblingSelectorBottomSheet" - private const val ARG_SCROBBLER = "scrobbler" - - fun show(fm: FragmentManager, manga: Manga, scrobblerService: ScrobblerService?) = - ScrobblingSelectorSheet().withArgs(2) { - putParcelable(MangaIntent.KEY_MANGA, ParcelableManga(manga)) - if (scrobblerService != null) { - putInt(ARG_SCROBBLER, scrobblerService.id) - } - }.show(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt index d6c016482..37d0f4384 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/common/ui/selector/ScrobblingSelectorViewModel.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.plus import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ExceptionResolver import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.util.ext.MutableEventFlow import org.koitharu.kotatsu.core.util.ext.call @@ -41,7 +41,7 @@ class ScrobblingSelectorViewModel @Inject constructor( private val historyRepository: HistoryRepository, ) : BaseViewModel() { - val manga = savedStateHandle.require(MangaIntent.KEY_MANGA).manga + val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga val availableScrobblers = scrobblers.filter { it.isEnabled } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt index a63a733c9..f06a0857a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/scrobbling/kitsu/ui/KitsuAuthActivity.kt @@ -1,18 +1,19 @@ package org.koitharu.kotatsu.scrobbling.kitsu.ui +import android.annotation.SuppressLint import android.content.Intent import android.os.Bundle import android.text.Editable -import android.text.TextWatcher import android.view.View import androidx.core.graphics.Insets import androidx.core.net.toUri import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.databinding.ActivityKitsuAuthBinding import org.koitharu.kotatsu.parsers.util.urlEncoded -class KitsuAuthActivity : BaseActivity(), View.OnClickListener, TextWatcher { +class KitsuAuthActivity : BaseActivity(), View.OnClickListener, DefaultTextWatcher { private val regexEmail = Regex("^[A-Z0-9._%+-]+@[A-Z0-9.-]+\\.[A-Z]{2,6}$", RegexOption.IGNORE_CASE) @@ -42,10 +43,6 @@ class KitsuAuthActivity : BaseActivity(), View.OnClick } } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - override fun afterTextChanged(s: Editable?) { val email = viewBinding.editEmail.text?.toString()?.trim() val password = viewBinding.editPassword.text?.toString()?.trim() @@ -55,6 +52,7 @@ class KitsuAuthActivity : BaseActivity(), View.OnClick && password.length >= 3 } + @SuppressLint("UnsafeImplicitIntentLaunch") private fun continueAuth() { val email = viewBinding.editEmail.text?.toString()?.trim().orEmpty() val password = viewBinding.editPassword.text?.toString()?.trim().orEmpty() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt index 989828470..b7dd3a741 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/MangaListActivity.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.search.ui -import android.content.Context -import android.content.Intent import android.os.Bundle import android.view.View import android.view.ViewGroup.MarginLayoutParams @@ -19,7 +17,6 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.MangaSource @@ -28,7 +25,8 @@ import org.koitharu.kotatsu.core.model.getTitle import org.koitharu.kotatsu.core.model.isNsfw import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga import org.koitharu.kotatsu.core.model.parcelable.ParcelableMangaListFilter -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.model.titleRes import org.koitharu.kotatsu.core.util.ViewBadge @@ -46,7 +44,6 @@ import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.parsers.util.isNullOrEmpty import org.koitharu.kotatsu.remotelist.ui.RemoteListFragment import kotlin.math.absoluteValue import com.google.android.material.R as materialR @@ -69,8 +66,8 @@ class MangaListActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(ActivityMangaListBinding.inflate(layoutInflater)) - val filter = intent.getParcelableExtraCompat(EXTRA_FILTER)?.filter - source = MangaSource(intent.getStringExtra(EXTRA_SOURCE)) + val filter = intent.getParcelableExtraCompat(AppRouter.KEY_FILTER)?.filter + source = MangaSource(intent.getStringExtra(AppRouter.KEY_SOURCE)) supportActionBar?.setDisplayHomeAsUpEnabled(true) if (viewBinding.containerFilterHeader != null) { viewBinding.appbar.addOnOffsetChangedListener(this) @@ -104,13 +101,13 @@ class MangaListActivity : override fun onClick(v: View) { when (v.id) { - R.id.button_order -> FilterSheetFragment.show(supportFragmentManager) + R.id.button_order -> router.showFilterSheet() } } fun showPreview(manga: Manga): Boolean = setSideFragment( PreviewFragment::class.java, - bundleOf(MangaIntent.KEY_MANGA to ParcelableManga(manga)), + bundleOf(AppRouter.KEY_MANGA to ParcelableManga(manga)), ) fun hidePreview() = setSideFragment(FilterSheetFragment::class.java, null) @@ -158,7 +155,6 @@ class MangaListActivity : filter.observe().observe(this) { snapshot -> chipSort.setTextAndVisible(snapshot.sortOrder.titleRes) filterBadge.counter = if (snapshot.listFilter.hasNonSearchOptions()) 1 else 0 - supportActionBar?.subtitle = snapshot.listFilter.query } } else { filter.observe().map { @@ -193,21 +189,4 @@ class MangaListActivity : filterOwner.filterCoordinator.set(filter) } } - - companion object { - - private const val EXTRA_FILTER = "filter" - private const val EXTRA_SOURCE = "source" - private const val ACTION_MANGA_EXPLORE = "${BuildConfig.APPLICATION_ID}.action.EXPLORE_MANGA" - - fun newIntent(context: Context, source: MangaSource, filter: MangaListFilter?): Intent = - Intent(context, MangaListActivity::class.java) - .setAction(ACTION_MANGA_EXPLORE) - .putExtra(EXTRA_SOURCE, source.name) - .apply { - if (!filter.isNullOrEmpty()) { - putExtra(EXTRA_FILTER, ParcelableMangaListFilter(filter)) - } - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt index c100efbcc..13b6e4460 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchActivity.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.search.ui.multi -import android.content.Context -import android.content.Intent import android.os.Bundle import android.view.Menu import android.view.MenuInflater @@ -15,6 +13,7 @@ import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.ListSelectionController @@ -25,9 +24,6 @@ import org.koitharu.kotatsu.core.util.ext.invalidateNestedItemDecorations import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivitySearchBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.download.ui.dialog.DownloadDialogFragment -import org.koitharu.kotatsu.favourites.ui.categories.select.FavoriteSheet import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.MangaSelectionDecoration import org.koitharu.kotatsu.list.ui.adapter.MangaListListener @@ -35,10 +31,7 @@ import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.list.ui.model.ListHeader import org.koitharu.kotatsu.list.ui.size.DynamicItemSizeResolver import org.koitharu.kotatsu.parsers.model.Manga -import org.koitharu.kotatsu.parsers.model.MangaListFilter import org.koitharu.kotatsu.parsers.model.MangaTag -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.search.ui.MangaListActivity import org.koitharu.kotatsu.search.ui.multi.adapter.SearchAdapter import javax.inject.Inject @@ -62,14 +55,8 @@ class SearchActivity : setContentView(ActivitySearchBinding.inflate(layoutInflater)) title = viewModel.query - val itemCLickListener = OnListItemClickListener { item, view -> - startActivity( - MangaListActivity.newIntent( - view.context, - item.source, - MangaListFilter(query = viewModel.query), - ), - ) + val itemClickListener = OnListItemClickListener { item, view -> + router.openSearch(item.source, viewModel.query) } val sizeResolver = DynamicItemSizeResolver(resources, settings, adjustWidth = true) val selectionDecoration = MangaSelectionDecoration(this) @@ -83,7 +70,7 @@ class SearchActivity : lifecycleOwner = this, coil = coil, listener = this, - itemClickListener = itemCLickListener, + itemClickListener = itemClickListener, sizeResolver = sizeResolver, selectionDecoration = selectionDecoration, ) @@ -98,8 +85,6 @@ class SearchActivity : viewModel.list.observe(this, adapter) viewModel.onError.observeEvent(this, SnackbarErrorObserver(viewBinding.recyclerView, null)) - - DownloadDialogFragment.registerCallback(this, viewBinding.recyclerView) } override fun onWindowInsetsChanged(insets: Insets) { @@ -114,8 +99,7 @@ class SearchActivity : override fun onItemClick(item: Manga, view: View) { if (!selectionController.onItemClick(item.id)) { - val intent = DetailsActivity.newIntent(this, item) - startActivity(intent) + router.openDetails(item) } } @@ -129,15 +113,13 @@ class SearchActivity : override fun onReadClick(manga: Manga, view: View) { if (!selectionController.onItemClick(manga.id)) { - val intent = IntentBuilder(this).manga(manga).build() - startActivity(intent) + router.openReader(manga) } } override fun onTagClick(manga: Manga, tag: MangaTag, view: View) { if (!selectionController.onItemClick(manga.id)) { - val intent = MangaListActivity.newIntent(this, manga.source, MangaListFilter(tags = setOf(tag))) - startActivity(intent) + router.openList(tag) } } @@ -179,13 +161,13 @@ class SearchActivity : } R.id.action_favourite -> { - FavoriteSheet.show(supportFragmentManager, collectSelectedItems()) + router.showFavoriteDialog(collectSelectedItems()) mode?.finish() true } R.id.action_save -> { - DownloadDialogFragment.show(supportFragmentManager, collectSelectedItems()) + router.showDownloadDialog(collectSelectedItems(), viewBinding.recyclerView) mode?.finish() true } @@ -197,13 +179,4 @@ class SearchActivity : private fun collectSelectedItems(): Set { return viewModel.getItems(selectionController.peekCheckedIds()) } - - companion object { - - const val EXTRA_QUERY = "query" - - fun newIntent(context: Context, query: String) = - Intent(context, SearchActivity::class.java) - .putExtra(EXTRA_QUERY, query) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt index c4cb96024..2a207c2ce 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/multi/SearchViewModel.kt @@ -25,6 +25,7 @@ import kotlinx.coroutines.sync.withPermit import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.ListMode import org.koitharu.kotatsu.core.ui.BaseViewModel @@ -58,7 +59,7 @@ class SearchViewModel @Inject constructor( private val favouritesRepository: FavouritesRepository, ) : BaseViewModel() { - val query = savedStateHandle.get(SearchActivity.EXTRA_QUERY).orEmpty() + val query = savedStateHandle.get(AppRouter.KEY_QUERY).orEmpty() private val retryCounter = MutableStateFlow(0) private val listData = retryCounter.flatMapLatest { @@ -125,6 +126,7 @@ class SearchViewModel @Inject constructor( mangaListMapper.toListModelList( manga = repository.getList(offset = 0, null, MangaListFilter(query = q)), mode = ListMode.GRID, + flags = 0, ) } }.fold( @@ -160,7 +162,7 @@ class SearchViewModel @Inject constructor( titleResId = R.string.history, source = UnknownMangaSource, hasMore = false, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID, flags = 0), error = null, ) } else { @@ -189,7 +191,7 @@ class SearchViewModel @Inject constructor( titleResId = R.string.favourites, source = UnknownMangaSource, hasMore = false, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID, flags = 0), error = null, ) } else { @@ -218,7 +220,7 @@ class SearchViewModel @Inject constructor( titleResId = 0, source = LocalMangaSource, hasMore = result.size > MIN_HAS_MORE_ITEMS, - list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID), + list = mangaListMapper.toListModelList(manga = result, mode = ListMode.GRID,flags = 0), error = null, ) } else { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt index 49b7d467c..084d20671 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/search/ui/suggestion/SearchSuggestionFragment.kt @@ -71,16 +71,4 @@ class SearchSuggestionFragment : super.onResume() viewModel.onResume() } - - companion object { - - @Deprecated( - "", - ReplaceWith( - "SearchSuggestionFragment()", - "org.koitharu.kotatsu.search.ui.suggestion.SearchSuggestionFragment", - ), - ) - fun newInstance() = SearchSuggestionFragment() - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt index eece51d8f..1afb711b7 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/DownloadsSettingsFragment.kt @@ -5,7 +5,6 @@ import android.content.SharedPreferences import android.net.Uri import android.os.Bundle import android.view.View -import androidx.activity.result.contract.ActivityResultContracts import androidx.documentfile.provider.DocumentFile import androidx.preference.ListPreference import androidx.preference.Preference @@ -15,6 +14,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router +import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.DownloadFormat import org.koitharu.kotatsu.core.prefs.TriStateOption @@ -27,8 +28,6 @@ import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalStorageManager import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.storage.MangaDirectorySelectDialog -import org.koitharu.kotatsu.settings.storage.directories.MangaDirectoriesActivity import org.koitharu.kotatsu.settings.utils.DozeHelper import javax.inject.Inject @@ -45,7 +44,7 @@ class DownloadsSettingsFragment : @Inject lateinit var downloadsScheduler: DownloadWorker.Scheduler - private val pickFileTreeLauncher = registerForActivityResult(ActivityResultContracts.OpenDocumentTree()) { + private val pickFileTreeLauncher = OpenDocumentTreeHelper(this) { if (it != null) onDirectoryPicked(it) } @@ -98,12 +97,12 @@ class DownloadsSettingsFragment : override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_LOCAL_STORAGE -> { - MangaDirectorySelectDialog.show(childFragmentManager) + router.showDirectorySelectDialog() true } AppSettings.KEY_LOCAL_MANGA_DIRS -> { - startActivity(MangaDirectoriesActivity.newIntent(preference.context)) + router.openDirectoriesSettings() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt index 052a1e4c1..439180a81 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ReaderSettingsFragment.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.settings -import android.content.Intent import android.content.SharedPreferences import android.content.pm.ActivityInfo import android.os.Bundle @@ -11,14 +10,16 @@ import androidx.preference.Preference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.ZoomMode +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ReaderAnimation import org.koitharu.kotatsu.core.prefs.ReaderBackground +import org.koitharu.kotatsu.core.prefs.ReaderControl import org.koitharu.kotatsu.core.prefs.ReaderMode import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat +import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.reader.ReaderTapGridConfigActivity import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.settings.utils.PercentSummaryProvider import org.koitharu.kotatsu.settings.utils.SliderPreference @@ -31,12 +32,7 @@ class ReaderSettingsFragment : override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_reader) findPreference(AppSettings.KEY_READER_MODE)?.run { - entryValues = arrayOf( - ReaderMode.STANDARD.name, - ReaderMode.REVERSED.name, - ReaderMode.VERTICAL.name, - ReaderMode.WEBTOON.name, - ) + entryValues = ReaderMode.entries.names() setDefaultValueCompat(ReaderMode.STANDARD.name) } findPreference(AppSettings.KEY_READER_ORIENTATION)?.run { @@ -48,6 +44,11 @@ class ReaderSettingsFragment : ) setDefaultValueCompat(ActivityInfo.SCREEN_ORIENTATION_UNSPECIFIED.toString()) } + findPreference(AppSettings.KEY_READER_CONTROLS)?.run { + entryValues = ReaderControl.entries.names() + setDefaultValueCompat(ReaderControl.DEFAULT.mapToSet { it.name }) + summaryProvider = MultiSummaryProvider(R.string.none) + } findPreference(AppSettings.KEY_READER_BACKGROUND)?.run { entryValues = ReaderBackground.entries.names() setDefaultValueCompat(ReaderBackground.DEFAULT.name) @@ -80,7 +81,7 @@ class ReaderSettingsFragment : override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { AppSettings.KEY_READER_TAP_ACTIONS -> { - startActivity(Intent(preference.context, ReaderTapGridConfigActivity::class.java)) + router.openReaderTapGridSettings() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt index af1f7a465..556cb481a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/ServicesSettingsFragment.kt @@ -1,7 +1,6 @@ package org.koitharu.kotatsu.settings import android.accounts.AccountManager -import android.content.Intent import android.content.SharedPreferences import android.os.Bundle import android.view.View @@ -12,6 +11,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog @@ -20,11 +20,8 @@ import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope import org.koitharu.kotatsu.scrobbling.common.domain.model.ScrobblerService import org.koitharu.kotatsu.scrobbling.common.ui.ScrobblerAuthHelper -import org.koitharu.kotatsu.scrobbling.common.ui.config.ScrobblerConfigActivity import org.koitharu.kotatsu.settings.utils.SplitSwitchPreference -import org.koitharu.kotatsu.stats.ui.StatsActivity import org.koitharu.kotatsu.sync.domain.SyncController -import org.koitharu.kotatsu.sync.ui.SyncSettingsIntent import javax.inject.Inject @AndroidEntryPoint @@ -41,7 +38,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), addPreferencesFromResource(R.xml.pref_services) findPreference(AppSettings.KEY_STATS_ENABLED)?.let { it.onContainerClickListener = Preference.OnPreferenceClickListener { - it.context.startActivity(Intent(it.context, StatsActivity::class.java)) + router.openStatistic() true } } @@ -105,7 +102,9 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), if (account == null) { am.addAccount(accountType, accountType, null, null, requireActivity(), null, null) } else { - startActivitySafe(SyncSettingsIntent(account)) + if (!router.openSystemSyncSettings(account)) { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + } } true } @@ -146,7 +145,7 @@ class ServicesSettingsFragment : BasePreferenceFragment(R.string.services), if (!scrobblerAuthHelper.isAuthorized(scrobblerService)) { confirmScrobblerAuth(scrobblerService) } else { - startActivity(ScrobblerConfigActivity.newIntent(context ?: return, scrobblerService)) + router.openScrobblerSettings(scrobblerService) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt index 18c86b69e..ecade6039 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/SettingsActivity.kt @@ -1,10 +1,7 @@ package org.koitharu.kotatsu.settings -import android.content.Context import android.content.Intent -import android.net.Uri import android.os.Bundle -import android.provider.Settings import android.view.ViewGroup.MarginLayoutParams import androidx.activity.viewModels import androidx.core.graphics.Insets @@ -19,23 +16,22 @@ import androidx.preference.Preference import androidx.preference.PreferenceFragmentCompat import com.google.android.material.appbar.AppBarLayout import dagger.hilt.android.AndroidEntryPoint -import org.koitharu.kotatsu.BuildConfig import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.MangaSource -import org.koitharu.kotatsu.core.model.MangaSourceInfo -import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.util.ext.buildBundle import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.textAndVisible import org.koitharu.kotatsu.databinding.ActivitySettingsBinding import org.koitharu.kotatsu.main.ui.owners.AppBarOwner -import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.settings.about.AboutSettingsFragment import org.koitharu.kotatsu.settings.search.SettingsItem import org.koitharu.kotatsu.settings.search.SettingsSearchFragment import org.koitharu.kotatsu.settings.search.SettingsSearchViewModel import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment +import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment.Companion.EXTRA_SOURCE import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.sources.manage.SourcesManageFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment @@ -142,18 +138,18 @@ class SettingsActivity : private fun openDefaultFragment() { val fragment = when (intent?.action) { - ACTION_READER -> ReaderSettingsFragment() - ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() - ACTION_HISTORY -> UserDataSettingsFragment() - ACTION_TRACKER -> TrackerSettingsFragment() - ACTION_SOURCES -> SourcesSettingsFragment() - ACTION_PROXY -> ProxySettingsFragment() - ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment() - ACTION_SOURCE -> SourceSettingsFragment.newInstance( + AppRouter.ACTION_READER -> ReaderSettingsFragment() + AppRouter.ACTION_SUGGESTIONS -> SuggestionsSettingsFragment() + AppRouter.ACTION_HISTORY -> UserDataSettingsFragment() + AppRouter.ACTION_TRACKER -> TrackerSettingsFragment() + AppRouter.ACTION_SOURCES -> SourcesSettingsFragment() + AppRouter.ACTION_PROXY -> ProxySettingsFragment() + AppRouter.ACTION_MANAGE_DOWNLOADS -> DownloadsSettingsFragment() + AppRouter.ACTION_SOURCE -> SourceSettingsFragment.newInstance( MangaSource(intent.getStringExtra(EXTRA_SOURCE)), ) - ACTION_MANAGE_SOURCES -> SourcesManageFragment() + AppRouter.ACTION_MANAGE_SOURCES -> SourcesManageFragment() Intent.ACTION_VIEW -> { when (intent.data?.host) { HOST_ABOUT -> AboutSettingsFragment() @@ -171,7 +167,7 @@ class SettingsActivity : } private fun navigateToPreference(item: SettingsItem) { - val args = Bundle(1).apply { + val args = buildBundle(1) { putString(ARG_PREF_KEY, item.key) } openFragment(item.fragmentClass, args, true) @@ -179,62 +175,8 @@ class SettingsActivity : companion object { - private const val ACTION_READER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_READER_SETTINGS" - private const val ACTION_SUGGESTIONS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SUGGESTIONS" - private const val ACTION_TRACKER = "${BuildConfig.APPLICATION_ID}.action.MANAGE_TRACKER" - private const val ACTION_HISTORY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_HISTORY" - private const val ACTION_SOURCE = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCE_SETTINGS" - private const val ACTION_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES" - private const val ACTION_MANAGE_SOURCES = "${BuildConfig.APPLICATION_ID}.action.MANAGE_SOURCES_LIST" - private const val ACTION_MANAGE_DOWNLOADS = "${BuildConfig.APPLICATION_ID}.action.MANAGE_DOWNLOADS" - private const val ACTION_PROXY = "${BuildConfig.APPLICATION_ID}.action.MANAGE_PROXY" - private const val EXTRA_SOURCE = "source" private const val HOST_ABOUT = "about" private const val HOST_SYNC_SETTINGS = "sync-settings" const val ARG_PREF_KEY = "pref_key" - - fun newIntent(context: Context) = Intent(context, SettingsActivity::class.java) - - fun newReaderSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_READER) - - fun newSuggestionsSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_SUGGESTIONS) - - fun newTrackerSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_TRACKER) - - fun newProxySettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_PROXY) - - fun newHistorySettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_HISTORY) - - fun newSourcesSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_SOURCES) - - fun newManageSourcesIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_MANAGE_SOURCES) - - fun newDownloadsSettingsIntent(context: Context) = - Intent(context, SettingsActivity::class.java) - .setAction(ACTION_MANAGE_DOWNLOADS) - - fun newSourceSettingsIntent(context: Context, source: MangaSource): Intent = when (source) { - is MangaSourceInfo -> newSourceSettingsIntent(context, source.mangaSource) - is ExternalMangaSource -> Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS) - .setData(Uri.fromParts("package", source.packageName, null)) - - else -> Intent(context, SettingsActivity::class.java) - .setAction(ACTION_SOURCE) - .putExtra(EXTRA_SOURCE, source.name) - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt index bbc9ee88b..e41d756a0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AboutSettingsFragment.kt @@ -4,7 +4,6 @@ import android.content.Intent import android.os.Bundle import android.view.View import androidx.annotation.StringRes -import androidx.core.net.toUri import androidx.fragment.app.viewModels import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat @@ -16,6 +15,7 @@ import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppVersion import org.koitharu.kotatsu.core.github.VersionId import org.koitharu.kotatsu.core.github.isStable +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.observe @@ -89,15 +89,13 @@ class AboutSettingsFragment : BasePreferenceFragment(R.string.about) { } } - private fun openLink(@StringRes url: Int, title: CharSequence?): Boolean { - val intent = Intent(Intent.ACTION_VIEW) - intent.data = getString(url).toUri() - return startActivitySafe( - if (title != null) { - Intent.createChooser(intent, title) - } else { - intent - }, - ) + private fun openLink( + @StringRes url: Int, + title: CharSequence? + ): Boolean = if (router.openExternalBrowser(getString(url), title)) { + true + } else { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + false } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt index 01bdc2b4a..d28971610 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/about/AppUpdateActivity.kt @@ -15,8 +15,8 @@ import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.viewModels import androidx.core.content.ContextCompat import androidx.core.graphics.Insets -import androidx.core.net.toUri import androidx.core.text.buildSpannedString +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import io.noties.markwon.Markwon import kotlinx.coroutines.Dispatchers @@ -24,6 +24,7 @@ import kotlinx.coroutines.flow.combine import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.github.AppVersion +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.getDisplayMessage @@ -137,8 +138,9 @@ class AppUpdateActivity : BaseActivity(), View.OnClick private fun openInBrowser() { val latestVersion = viewModel.nextVersion.value ?: return - val intent = Intent(Intent.ACTION_VIEW, latestVersion.url.toUri()) - startActivity(Intent.createChooser(intent, getString(R.string.open_in_browser))) + if (!router.openExternalBrowser(latestVersion.url, getString(R.string.open_in_browser))) { + Snackbar.make(viewBinding.scrollView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + } } private fun onProgressChanged(value: Pair) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt index 61b64637a..cef00b6f4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/AppBackupAgent.kt @@ -97,9 +97,9 @@ class AppBackupAgent : BackupAgent() { } try { runBlocking { - backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it) } + backup.getEntry(BackupEntry.Name.HISTORY)?.let { repository.restoreHistory(it, null) } backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { repository.restoreCategories(it) } - backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it) } + backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { repository.restoreFavourites(it, null) } backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { repository.restoreBookmarks(it) } backup.getEntry(BackupEntry.Name.SOURCES)?.let { repository.restoreSources(it) } backup.getEntry(BackupEntry.Name.SETTINGS)?.let { repository.restoreSettings(it) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt index b40157ab0..1bae292a9 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/BackupDialogFragment.kt @@ -7,7 +7,6 @@ import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts import androidx.core.view.isVisible -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint @@ -103,13 +102,4 @@ class BackupDialogFragment : AlertDialogFragment() { onError(e) } } - - companion object { - - private const val TAG = "BackupDialogFragment" - - fun show(fm: FragmentManager) { - BackupDialogFragment().show(fm, TAG) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt index 51b90f8e0..8b7959fdd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupService.kt @@ -5,6 +5,7 @@ import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipOutput import org.koitharu.kotatsu.core.backup.ExternalBackupStorage +import org.koitharu.kotatsu.core.backup.TelegramBackupUploader import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.CoroutineIntentService import javax.inject.Inject @@ -15,6 +16,9 @@ class PeriodicalBackupService : CoroutineIntentService() { @Inject lateinit var externalBackupStorage: ExternalBackupStorage + @Inject + lateinit var telegramBackupUploader: TelegramBackupUploader + @Inject lateinit var repository: BackupRepository @@ -43,6 +47,9 @@ class PeriodicalBackupService : CoroutineIntentService() { } externalBackupStorage.put(output.file) externalBackupStorage.trim(settings.periodicalBackupMaxCount) + if (settings.isBackupTelegramUploadEnabled) { + telegramBackupUploader.uploadBackup(output.file) + } } finally { output.file.delete() } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt index 4e8e9ddb4..37262769a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsFragment.kt @@ -1,28 +1,28 @@ package org.koitharu.kotatsu.settings.backup -import android.content.Context import android.content.Intent import android.net.Uri import android.os.Bundle import android.text.format.DateUtils import android.view.View import androidx.activity.result.ActivityResultCallback -import androidx.activity.result.contract.ActivityResultContracts -import androidx.documentfile.provider.DocumentFile +import androidx.fragment.app.viewModels +import androidx.preference.EditTextPreference import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.backup.BackupZipOutput.Companion.DIR_BACKUPS -import org.koitharu.kotatsu.core.backup.ExternalBackupStorage +import org.koitharu.kotatsu.core.backup.TelegramBackupUploader +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router +import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.util.ext.resolveFile +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.tryLaunch -import org.koitharu.kotatsu.core.util.ext.viewLifecycleScope -import java.io.File +import org.koitharu.kotatsu.settings.utils.EditTextFallbackSummaryProvider +import java.util.Date import javax.inject.Inject @AndroidEntryPoint @@ -30,28 +30,43 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi ActivityResultCallback { @Inject - lateinit var backupStorage: ExternalBackupStorage + lateinit var telegramBackupUploader: TelegramBackupUploader - private val outputSelectCall = registerForActivityResult( - ActivityResultContracts.OpenDocumentTree(), - this, - ) + private val viewModel by viewModels() + + private val outputSelectCall = OpenDocumentTreeHelper(this, this) override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { addPreferencesFromResource(R.xml.pref_backup_periodic) + findPreference(AppSettings.KEY_BACKUP_TG_CHAT)?.summaryProvider = + EditTextFallbackSummaryProvider(R.string.telegram_chat_id_summary) } override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - bindOutputSummary() - bindLastBackupInfo() + viewModel.lastBackupDate.observe(viewLifecycleOwner, ::bindLastBackupInfo) + viewModel.backupsDirectory.observe(viewLifecycleOwner, ::bindOutputSummary) + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) + viewModel.isTelegramCheckLoading.observe(viewLifecycleOwner) { + findPreference(AppSettings.KEY_BACKUP_TG_TEST)?.isEnabled = !it + } } override fun onPreferenceTreeClick(preference: Preference): Boolean { - return when (preference.key) { + val result = when (preference.key) { AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT -> outputSelectCall.tryLaunch(null) - else -> super.onPreferenceTreeClick(preference) + AppSettings.KEY_BACKUP_TG_OPEN -> telegramBackupUploader.openBotInApp(router) + AppSettings.KEY_BACKUP_TG_TEST -> { + viewModel.checkTelegram() + true + } + + else -> return super.onPreferenceTreeClick(preference) + } + if (!result) { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() } + return true } override fun onActivityResult(result: Uri?) { @@ -59,44 +74,28 @@ class PeriodicalBackupSettingsFragment : BasePreferenceFragment(R.string.periodi val takeFlags: Int = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_GRANT_WRITE_URI_PERMISSION context?.contentResolver?.takePersistableUriPermission(result, takeFlags) settings.periodicalBackupDirectory = result - bindOutputSummary() - bindLastBackupInfo() + viewModel.updateSummaryData() } } - private fun bindOutputSummary() { + private fun bindOutputSummary(path: String?) { val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_OUTPUT) ?: return - viewLifecycleScope.launch { - preference.summary = withContext(Dispatchers.Default) { - val value = settings.periodicalBackupDirectory - value?.toUserFriendlyString(preference.context) ?: preference.context.run { - getExternalFilesDir(DIR_BACKUPS) ?: File(filesDir, DIR_BACKUPS) - }.path - } + preference.summary = when (path) { + null -> getString(R.string.invalid_value_message) + "" -> null + else -> path } } - private fun bindLastBackupInfo() { + private fun bindLastBackupInfo(lastBackupDate: Date?) { val preference = findPreference(AppSettings.KEY_BACKUP_PERIODICAL_LAST) ?: return - viewLifecycleScope.launch { - val lastDate = withContext(Dispatchers.Default) { - backupStorage.getLastBackupDate() - } - preference.summary = lastDate?.let { - preference.context.getString( - R.string.last_successful_backup, - DateUtils.getRelativeTimeSpanString(it.time), - ) - } - preference.isVisible = lastDate != null - } - } - - private fun Uri.toUserFriendlyString(context: Context): String { - val df = DocumentFile.fromTreeUri(context, this) - if (df?.canWrite() != true) { - return context.getString(R.string.invalid_value_message) + preference.summary = lastBackupDate?.let { + preference.context.getString( + R.string.last_successful_backup, + DateUtils.getRelativeTimeSpanString(it.time), + ) } - return resolveFile(context)?.path ?: toString() + preference.isVisible = lastBackupDate != null } } + diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt new file mode 100644 index 000000000..f557ed11d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/PeriodicalBackupSettingsViewModel.kt @@ -0,0 +1,78 @@ +package org.koitharu.kotatsu.settings.backup + +import android.content.Context +import android.net.Uri +import androidx.documentfile.provider.DocumentFile +import dagger.hilt.android.lifecycle.HiltViewModel +import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.backup.BackupZipOutput.Companion.DIR_BACKUPS +import org.koitharu.kotatsu.core.backup.ExternalBackupStorage +import org.koitharu.kotatsu.core.backup.TelegramBackupUploader +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.util.ReversibleAction +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.resolveFile +import java.io.File +import java.util.Date +import javax.inject.Inject + +@HiltViewModel +class PeriodicalBackupSettingsViewModel @Inject constructor( + private val settings: AppSettings, + private val telegramUploader: TelegramBackupUploader, + private val backupStorage: ExternalBackupStorage, + @ApplicationContext private val appContext: Context, +) : BaseViewModel() { + + val lastBackupDate = MutableStateFlow(null) + val backupsDirectory = MutableStateFlow("") + val isTelegramCheckLoading = MutableStateFlow(false) + val onActionDone = MutableEventFlow() + + init { + updateSummaryData() + } + + fun checkTelegram() { + launchJob(Dispatchers.Default) { + try { + isTelegramCheckLoading.value = true + telegramUploader.sendTestMessage() + onActionDone.call(ReversibleAction(R.string.connection_ok, null)) + } finally { + isTelegramCheckLoading.value = false + } + } + } + + fun updateSummaryData() { + updateBackupsDirectory() + updateLastBackupDate() + } + + private fun updateBackupsDirectory() = launchJob(Dispatchers.Default) { + val dir = settings.periodicalBackupDirectory + backupsDirectory.value = if (dir != null) { + dir.toUserFriendlyString() + } else { + (appContext.getExternalFilesDir(DIR_BACKUPS) ?: File(appContext.filesDir, DIR_BACKUPS)).path + } + } + + private fun updateLastBackupDate() = launchJob(Dispatchers.Default) { + lastBackupDate.value = backupStorage.getLastBackupDate() + } + + private fun Uri.toUserFriendlyString(): String? { + val df = DocumentFile.fromTreeUri(appContext, this) + if (df?.canWrite() != true) { + return null + } + return resolveFile(appContext)?.path ?: toString() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt index 11e16e4e2..efa040479 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreDialogFragment.kt @@ -1,32 +1,28 @@ package org.koitharu.kotatsu.settings.backup -import android.net.Uri import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.widget.Toast import androidx.core.view.isGone import androidx.core.view.isVisible -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.backup.CompositeResult +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.DialogRestoreBinding -import org.koitharu.kotatsu.main.ui.welcome.WelcomeSheet import java.text.DateFormat import java.text.SimpleDateFormat import java.util.Date -import kotlin.math.roundToInt @AndroidEntryPoint class RestoreDialogFragment : AlertDialogFragment(), OnListItemClickListener, @@ -46,8 +42,6 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis binding.buttonCancel.setOnClickListener(this) binding.buttonRestore.setOnClickListener(this) viewModel.availableEntries.observe(viewLifecycleOwner, adapter) - viewModel.progress.observe(viewLifecycleOwner, this::onProgressChanged) - viewModel.onRestoreDone.observeEvent(viewLifecycleOwner, this::onRestoreDone) viewModel.onError.observeEvent(viewLifecycleOwner, this::onError) combine( viewModel.isLoading, @@ -66,7 +60,15 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis override fun onClick(v: View) { when (v.id) { R.id.button_cancel -> dismiss() - R.id.button_restore -> viewModel.restore() + R.id.button_restore -> { + if (startRestoreService()) { + Toast.makeText(v.context, R.string.backup_restored_background, Toast.LENGTH_SHORT).show() + router.closeWelcomeSheet() + dismiss() + } else { + Toast.makeText(v.context, R.string.operation_not_supported, Toast.LENGTH_SHORT).show() + } + } } } @@ -90,6 +92,14 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis } } + private fun startRestoreService(): Boolean { + return RestoreService.start( + context ?: return false, + viewModel.uri ?: return false, + viewModel.getCheckedEntries(), + ) + } + private fun Date.formatBackupDate(): String { return getString( R.string.backup_date_, @@ -105,59 +115,4 @@ class RestoreDialogFragment : AlertDialogFragment(), OnLis .show() dismiss() } - - private fun onProgressChanged(value: Float) { - with(requireViewBinding().progressBar) { - isVisible = true - val wasIndeterminate = isIndeterminate - isIndeterminate = value < 0 - if (value >= 0) { - setProgressCompat((value * max).roundToInt(), !wasIndeterminate) - } - } - } - - private fun onRestoreDone(result: CompositeResult) { - val builder = MaterialAlertDialogBuilder(context ?: return) - when { - result.isEmpty -> { - builder.setTitle(R.string.data_not_restored) - .setMessage(R.string.data_not_restored_text) - } - - result.isAllSuccess -> { - builder.setTitle(R.string.data_restored) - .setMessage(R.string.data_restored_success) - } - - result.isAllFailed -> builder.setTitle(R.string.error) - .setMessage( - result.failures.map { - it.getDisplayMessage(resources) - }.distinct().joinToString("\n"), - ) - - else -> builder.setTitle(R.string.data_restored) - .setMessage(R.string.data_restored_with_errors) - } - builder.setPositiveButton(android.R.string.ok, null) - .show() - if (!result.isEmpty && !result.isAllFailed) { - WelcomeSheet.dismiss(parentFragmentManager) - } - dismiss() - } - - - companion object { - - const val ARG_FILE = "file" - private const val TAG = "RestoreDialogFragment" - - fun show(fm: FragmentManager, uri: Uri) { - RestoreDialogFragment().withArgs(1) { - putString(ARG_FILE, uri.toString()) - }.show(fm, TAG) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreService.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreService.kt new file mode 100644 index 000000000..699690afb --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreService.kt @@ -0,0 +1,287 @@ +package org.koitharu.kotatsu.settings.backup + +import android.annotation.SuppressLint +import android.app.Notification +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.net.Uri +import androidx.core.app.NotificationChannelCompat +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.app.PendingIntentCompat +import androidx.core.content.ContextCompat +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.runInterruptible +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.ErrorReporterReceiver +import org.koitharu.kotatsu.core.backup.BackupEntry +import org.koitharu.kotatsu.core.backup.BackupRepository +import org.koitharu.kotatsu.core.backup.BackupZipInput +import org.koitharu.kotatsu.core.backup.CompositeResult +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.ui.CoroutineIntentService +import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission +import org.koitharu.kotatsu.core.util.ext.getDisplayMessage +import org.koitharu.kotatsu.core.util.ext.getFileDisplayName +import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug +import org.koitharu.kotatsu.core.util.ext.toUriOrNull +import org.koitharu.kotatsu.core.util.progress.Progress +import org.koitharu.kotatsu.parsers.util.mapToArray +import org.koitharu.kotatsu.parsers.util.nullIfEmpty +import java.io.File +import java.io.FileNotFoundException +import java.util.EnumSet +import javax.inject.Inject +import com.google.android.material.R as materialR + +@AndroidEntryPoint +class RestoreService : CoroutineIntentService() { + + @Inject + lateinit var repository: BackupRepository + + private lateinit var notificationManager: NotificationManagerCompat + + override fun onCreate() { + super.onCreate() + notificationManager = NotificationManagerCompat.from(applicationContext) + } + + override suspend fun IntentJobContext.processIntent(intent: Intent) { + startForeground(this) + val uri = intent.getStringExtra(AppRouter.KEY_DATA)?.toUriOrNull() ?: throw FileNotFoundException() + val displayName = contentResolver.getFileDisplayName(uri) + val entries = intent.getIntArrayExtra(AppRouter.KEY_ENTRIES) + ?.mapTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { BackupEntry.Name.entries[it] } + if (entries.isNullOrEmpty()) { + throw IllegalArgumentException("No entries specified") + } + val result = runInterruptible(Dispatchers.IO) { + val tempFile = File.createTempFile("backup_", ".tmp") + (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> + tempFile.outputStream().use { output -> + input.copyTo(output) + } + } + BackupZipInput.from(tempFile) + }.use { backupInput -> + restoreImpl(displayName, backupInput, entries) + } + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val notification = buildNotification(displayName, result) + notificationManager.notify(TAG, startId, notification) + } + } + + override fun IntentJobContext.onError(error: Throwable) { + if (applicationContext.checkNotificationPermission(CHANNEL_ID)) { + val result = CompositeResult() + result += error + val notification = buildNotification(null, result) + notificationManager.notify(TAG, startId, notification) + } + } + + private suspend fun IntentJobContext.restoreImpl( + displayName: String?, + input: BackupZipInput, + entries: Set + ): CompositeResult { + val result = CompositeResult() + val showNotification = applicationContext.checkNotificationPermission(CHANNEL_ID) + var progress = Progress(0, entries.size) + + fun notify(childProgress: Progress? = null) { + if (showNotification) { + val p = childProgress?.let { progress + it } ?: progress + notificationManager.notify(FOREGROUND_NOTIFICATION_ID, buildNotification(displayName, p)) + } + } + + notify() + + if (BackupEntry.Name.HISTORY in entries) { + input.getEntry(BackupEntry.Name.HISTORY)?.let { + flow { + result += repository.restoreHistory(it, this) + }.collect { p -> + notify(p) + } + } + progress++ + } + + notify() + + if (BackupEntry.Name.CATEGORIES in entries) { + input.getEntry(BackupEntry.Name.CATEGORIES)?.let { + result += repository.restoreCategories(it) + } + progress++ + } + + notify() + + if (BackupEntry.Name.FAVOURITES in entries) { + input.getEntry(BackupEntry.Name.FAVOURITES)?.let { + flow { + result += repository.restoreFavourites(it, this) + }.collect { p -> + notify(p) + } + } + } + + notify() + + if (BackupEntry.Name.BOOKMARKS in entries) { + input.getEntry(BackupEntry.Name.BOOKMARKS)?.let { + result += repository.restoreBookmarks(it) + } + progress++ + } + + notify() + + if (BackupEntry.Name.SOURCES in entries) { + input.getEntry(BackupEntry.Name.SOURCES)?.let { + result += repository.restoreSources(it) + } + progress++ + } + + notify() + + if (BackupEntry.Name.SETTINGS in entries) { + input.getEntry(BackupEntry.Name.SETTINGS)?.let { + result += repository.restoreSettings(it) + } + progress++ + } + + notify() + + return result + } + + @SuppressLint("InlinedApi") + private fun startForeground(jobContext: IntentJobContext) { + val channel = NotificationChannelCompat.Builder(CHANNEL_ID, NotificationManagerCompat.IMPORTANCE_LOW) + .setName(getString(R.string.restoring_backup)) + .setShowBadge(true) + .setVibrationEnabled(false) + .setSound(null, null) + .setLightsEnabled(false) + .build() + notificationManager.createNotificationChannel(channel) + + val notification = jobContext.buildNotification(null, null) + + jobContext.setForeground( + FOREGROUND_NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC, + ) + } + + private fun IntentJobContext.buildNotification(fileName: String?, progress: Progress?): Notification { + return NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setContentTitle(getString(R.string.restoring_backup)) + .setPriority(NotificationCompat.PRIORITY_LOW) + .setDefaults(0) + .setSilent(true) + .setOngoing(true) + .setProgress(progress?.total ?: 0, progress?.progress ?: 0, progress == null) + .setContentText( + concatStrings( + context = this@RestoreService, + a = fileName, + b = progress?.run { getString(R.string.percent_string_pattern, percentSting()) }, + ), + ) + .setSmallIcon(android.R.drawable.stat_sys_download) + .setForegroundServiceBehavior(NotificationCompat.FOREGROUND_SERVICE_IMMEDIATE) + .setCategory(NotificationCompat.CATEGORY_PROGRESS) + .addAction( + materialR.drawable.material_ic_clear_black_24dp, + applicationContext.getString(android.R.string.cancel), + getCancelIntent(), + ).build() + } + + private fun buildNotification(fileName: String?, result: CompositeResult): Notification { + val notification = NotificationCompat.Builder(applicationContext, CHANNEL_ID) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setDefaults(0) + .setSilent(true) + .setAutoCancel(true) + .setSubText(fileName) + + when { + result.isEmpty -> notification.setContentTitle(getString(R.string.data_not_restored)) + .setContentText(getString(R.string.data_not_restored_text)) + .setSmallIcon(android.R.drawable.stat_notify_error) + + result.isAllSuccess -> notification.setContentTitle(getString(R.string.data_restored)) + .setContentText(getString(R.string.data_restored_success)) + .setSmallIcon(R.drawable.ic_stat_done) + + result.isAllFailed -> notification.setContentTitle(getString(R.string.error)) + .setContentText( + result.failures.map { it.getDisplayMessage(resources) }.distinct().joinToString("\n"), + ) + .setSmallIcon(android.R.drawable.stat_notify_error) + + else -> notification.setContentTitle(getString(R.string.data_restored)) + .setContentText(getString(R.string.data_restored_with_errors)) + .setSmallIcon(R.drawable.ic_stat_done) + } + result.failures.firstOrNull()?.let { error -> + ErrorReporterReceiver.getPendingIntent(applicationContext, error)?.let { reportIntent -> + notification.addAction( + R.drawable.ic_alert_outline, + applicationContext.getString(R.string.report), + reportIntent, + ) + } + } + notification.setContentIntent( + PendingIntentCompat.getActivity( + applicationContext, + 0, + AppRouter.homeIntent(this), + 0, + false, + ), + ) + return notification.build() + } + + private fun concatStrings(context: Context, a: String?, b: String?): String? = when { + a.isNullOrEmpty() && b.isNullOrEmpty() -> null + a.isNullOrEmpty() -> b?.nullIfEmpty() + b.isNullOrEmpty() -> a.nullIfEmpty() + else -> context.getString(R.string.download_summary_pattern, a, b) + } + + companion object { + + private const val TAG = "restore" + private const val CHANNEL_ID = "restore_backup" + private const val FOREGROUND_NOTIFICATION_ID = 39 + + fun start(context: Context, uri: Uri, entries: Set): Boolean = try { + val intent = Intent(context, RestoreService::class.java) + intent.putExtra(AppRouter.KEY_DATA, uri.toString()) + intent.putExtra(AppRouter.KEY_ENTRIES, entries.mapToArray { it.ordinal }.toIntArray()) + ContextCompat.startForegroundService(context, intent) + true + } catch (e: Exception) { + e.printStackTraceDebug() + false + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt index 6aa77ded3..525602579 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/backup/RestoreViewModel.kt @@ -10,13 +10,9 @@ import kotlinx.coroutines.runInterruptible import org.koitharu.kotatsu.core.backup.BackupEntry import org.koitharu.kotatsu.core.backup.BackupRepository import org.koitharu.kotatsu.core.backup.BackupZipInput -import org.koitharu.kotatsu.core.backup.CompositeResult +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.printStackTraceDebug import org.koitharu.kotatsu.core.util.ext.toUriOrNull -import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy import java.io.File import java.io.FileNotFoundException import java.util.Date @@ -31,30 +27,28 @@ class RestoreViewModel @Inject constructor( @ApplicationContext context: Context, ) : BaseViewModel() { - private val backupInput = suspendLazy { - val uri = savedStateHandle.get(RestoreDialogFragment.ARG_FILE) - ?.toUriOrNull() ?: throw FileNotFoundException() - val contentResolver = context.contentResolver + val uri = savedStateHandle.get(AppRouter.KEY_FILE)?.toUriOrNull() + private val contentResolver = context.contentResolver + + val availableEntries = MutableStateFlow>(emptyList()) + val backupDate = MutableStateFlow(null) + + init { + launchLoadingJob(Dispatchers.Default) { + loadBackupInfo() + } + } + + private suspend fun loadBackupInfo() { runInterruptible(Dispatchers.IO) { val tempFile = File.createTempFile("backup_", ".tmp") - (contentResolver.openInputStream(uri) ?: throw FileNotFoundException()).use { input -> + (uri?.let { contentResolver.openInputStream(it) } ?: throw FileNotFoundException()).use { input -> tempFile.outputStream().use { output -> input.copyTo(output) } } BackupZipInput.from(tempFile) - } - } - - val progress = MutableStateFlow(-1f) - val onRestoreDone = MutableEventFlow() - - val availableEntries = MutableStateFlow>(emptyList()) - val backupDate = MutableStateFlow(null) - - init { - launchLoadingJob(Dispatchers.Default) { - val backup = backupInput.get() + }.use { backup -> val entries = backup.entries() availableEntries.value = BackupEntry.Name.entries.mapNotNull { entry -> if (entry == BackupEntry.Name.INDEX || entry !in entries) { @@ -70,15 +64,6 @@ class RestoreViewModel @Inject constructor( } } - override fun onCleared() { - super.onCleared() - runCatching { - backupInput.peek()?.closeAndDelete() - }.onFailure { - it.printStackTraceDebug() - } - } - fun onItemClick(item: BackupEntryModel) { val map = availableEntries.value.associateByTo(EnumMap(BackupEntry.Name::class.java)) { it.name } map[item.name] = item.copy(isChecked = !item.isChecked) @@ -86,61 +71,10 @@ class RestoreViewModel @Inject constructor( availableEntries.value = map.values.sortedBy { it.name.ordinal } } - fun restore() { - launchLoadingJob { - val backup = backupInput.get() - val checkedItems = availableEntries.value.mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { - if (it.isChecked) it.name else null - } - val result = CompositeResult() - val step = 1f / 6f - - progress.value = 0f - if (BackupEntry.Name.HISTORY in checkedItems) { - backup.getEntry(BackupEntry.Name.HISTORY)?.let { - result += repository.restoreHistory(it) - } - } - - progress.value += step - if (BackupEntry.Name.CATEGORIES in checkedItems) { - backup.getEntry(BackupEntry.Name.CATEGORIES)?.let { - result += repository.restoreCategories(it) - } - } - - progress.value += step - if (BackupEntry.Name.FAVOURITES in checkedItems) { - backup.getEntry(BackupEntry.Name.FAVOURITES)?.let { - result += repository.restoreFavourites(it) - } - } - - progress.value += step - if (BackupEntry.Name.BOOKMARKS in checkedItems) { - backup.getEntry(BackupEntry.Name.BOOKMARKS)?.let { - result += repository.restoreBookmarks(it) - } - } - - progress.value += step - if (BackupEntry.Name.SOURCES in checkedItems) { - backup.getEntry(BackupEntry.Name.SOURCES)?.let { - result += repository.restoreSources(it) - } - } - - progress.value += step - if (BackupEntry.Name.SETTINGS in checkedItems) { - backup.getEntry(BackupEntry.Name.SETTINGS)?.let { - result += repository.restoreSettings(it) - } - } - - progress.value = 1f - onRestoreDone.call(result) + fun getCheckedEntries(): Set = availableEntries.value + .mapNotNullTo(EnumSet.noneOf(BackupEntry.Name::class.java)) { + if (it.isChecked) it.name else null } - } /** * Check for inconsistent user selection diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt index 5b6883a6c..6a10558b8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/nav/NavConfigViewModel.kt @@ -19,6 +19,7 @@ import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle import org.koitharu.kotatsu.list.ui.model.ListModel import org.koitharu.kotatsu.main.ui.MainActivity +import org.koitharu.kotatsu.main.ui.MainNavigationDelegate import org.koitharu.kotatsu.parsers.util.move import org.koitharu.kotatsu.settings.nav.model.NavItemAddModel import org.koitharu.kotatsu.settings.nav.model.NavItemConfigModel @@ -38,7 +39,7 @@ class NavConfigViewModel @Inject constructor( NavItemConfigModel(it, getUnavailabilityHint(it)) } if (size < NavItem.entries.size) { - add(NavItemAddModel(size < 5)) + add(NavItemAddModel(size < MainNavigationDelegate.MAX_ITEM_COUNT)) } } }.stateIn( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt index cc086c8cd..a4d62e73e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/protect/ProtectSetupActivity.kt @@ -4,7 +4,6 @@ import android.content.pm.PackageManager import android.os.Build import android.os.Bundle import android.text.Editable -import android.text.TextWatcher import android.view.KeyEvent import android.view.View import android.view.WindowManager @@ -18,6 +17,7 @@ import androidx.core.view.isVisible import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.ActivitySetupProtectBinding @@ -27,7 +27,7 @@ private const val MIN_PASSWORD_LENGTH = 4 @AndroidEntryPoint class ProtectSetupActivity : BaseActivity(), - TextWatcher, + DefaultTextWatcher, View.OnClickListener, TextView.OnEditorActionListener, CompoundButton.OnCheckedChangeListener { @@ -90,10 +90,6 @@ class ProtectSetupActivity : } } - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit - override fun afterTextChanged(s: Editable?) { viewBinding.editPassword.error = null val isEnoughLength = (s?.length ?: 0) >= MIN_PASSWORD_LENGTH diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt index d0411d663..03a444eb2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/search/SettingsSearchHelper.kt @@ -22,6 +22,7 @@ import org.koitharu.kotatsu.settings.backup.PeriodicalBackupSettingsFragment import org.koitharu.kotatsu.settings.sources.SourcesSettingsFragment import org.koitharu.kotatsu.settings.tracker.TrackerSettingsFragment import org.koitharu.kotatsu.settings.userdata.UserDataSettingsFragment +import org.koitharu.kotatsu.settings.userdata.storage.StorageManageSettingsFragment import javax.inject.Inject @Reusable @@ -38,6 +39,12 @@ class SettingsSearchHelper @Inject constructor( preferenceManager.inflateTo(result, R.xml.pref_reader, emptyList(), ReaderSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_network, emptyList(), NetworkSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_user_data, emptyList(), UserDataSettingsFragment::class.java) + preferenceManager.inflateTo( + result, + R.xml.pref_storage, + listOf(context.getString(R.string.data_and_privacy)), + StorageManageSettingsFragment::class.java, + ) preferenceManager.inflateTo(result, R.xml.pref_downloads, emptyList(), DownloadsSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_tracker, emptyList(), TrackerSettingsFragment::class.java) preferenceManager.inflateTo(result, R.xml.pref_services, emptyList(), ServicesSettingsFragment::class.java) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt index 88d62e1b5..f183b971e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourceSettingsFragment.kt @@ -7,9 +7,9 @@ import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.browser.BrowserActivity import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver import org.koitharu.kotatsu.core.model.getTitle +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.parser.EmptyMangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings @@ -20,7 +20,6 @@ import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.parsers.model.MangaSource -import org.koitharu.kotatsu.settings.sources.auth.SourceAuthActivity import java.io.File @AndroidEntryPoint @@ -43,7 +42,7 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc val isValidSource = viewModel.repository !is EmptyMangaRepository findPreference(KEY_ENABLE)?.run { - isVisible = isValidSource + isVisible = isValidSource && !settings.isAllSourcesEnabled onPreferenceChangeListener = this@SourceSettingsFragment } findPreference(KEY_AUTH)?.run { @@ -87,18 +86,15 @@ class SourceSettingsFragment : BasePreferenceFragment(0), Preference.OnPreferenc override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { KEY_AUTH -> { - startActivity(SourceAuthActivity.newIntent(preference.context, viewModel.source)) + router.openSourceAuth(viewModel.source) true } AppSettings.KEY_OPEN_BROWSER -> { - startActivity( - BrowserActivity.newIntent( - context = preference.context, - url = viewModel.browserUrl.value ?: return false, - source = viewModel.source, - title = viewModel.source.getTitle(preference.context), - ), + router.openBrowser( + url = viewModel.browserUrl.value ?: return false, + source = viewModel.source, + title = viewModel.source.getTitle(preference.context), ) true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt index 49ed190bc..66d91e725 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/SourcesSettingsFragment.kt @@ -1,6 +1,6 @@ package org.koitharu.kotatsu.settings.sources -import android.content.Intent +import android.content.SharedPreferences import android.os.Bundle import android.view.View import androidx.fragment.app.viewModels @@ -9,16 +9,17 @@ import androidx.preference.Preference import androidx.preference.TwoStatePreference import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.explore.data.SourcesSortOrder import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity @AndroidEntryPoint -class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) { +class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources), + SharedPreferences.OnSharedPreferenceChangeListener { private val viewModel by viewModels() @@ -44,10 +45,10 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) } findPreference(AppSettings.KEY_SOURCES_CATALOG)?.let { pref -> viewModel.availableSourcesCount.observe(viewLifecycleOwner) { - pref.summary = if (it >= 0) { - getString(R.string.available_d, it) - } else { - null + pref.summary = when { + it == 0 -> getString(R.string.all_sources_enabled) + it > 0 -> getString(R.string.available_d, it) + else -> null } } } @@ -56,11 +57,18 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) pref.isChecked = it } } + updateEnableAllDependencies() + settings.subscribe(this) + } + + override fun onDestroyView() { + settings.unsubscribe(this) + super.onDestroyView() } override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { AppSettings.KEY_SOURCES_CATALOG -> { - startActivity(Intent(preference.context, SourcesCatalogActivity::class.java)) + router.openSourcesCatalog() true } @@ -71,4 +79,14 @@ class SourcesSettingsFragment : BasePreferenceFragment(R.string.remote_sources) else -> super.onPreferenceTreeClick(preference) } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + when (key) { + AppSettings.KEY_SOURCES_ENABLED_ALL -> updateEnableAllDependencies() + } + } + + private fun updateEnableAllDependencies() { + findPreference(AppSettings.KEY_SOURCES_CATALOG)?.isEnabled = !settings.isAllSourcesEnabled + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt index 9d705714d..d114b3036 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/adapter/SourceConfigAdapterDelegates.kt @@ -58,7 +58,7 @@ fun sourceConfigItemDelegate2( bind { binding.textViewTitle.text = item.source.getTitle(context) binding.imageViewAdd.isGone = item.isEnabled || !item.isAvailable - binding.imageViewRemove.isVisible = item.isEnabled + binding.imageViewRemove.isVisible = item.isEnabled && item.isDisableAvailable binding.imageViewMenu.isVisible = item.isEnabled binding.textViewTitle.drawableStart = if (item.isPinned) iconPinned else null binding.textViewDescription.text = item.source.getSummary(context) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt index ea285a8a4..50a01749c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/auth/SourceAuthActivity.kt @@ -1,12 +1,10 @@ package org.koitharu.kotatsu.settings.sources.auth import android.annotation.SuppressLint -import android.app.Activity import android.content.Context import android.content.Intent import android.os.Bundle import android.view.MenuItem -import android.webkit.CookieManager import android.widget.Toast import androidx.activity.result.contract.ActivityResultContract import androidx.core.graphics.Insets @@ -19,6 +17,7 @@ import org.koitharu.kotatsu.browser.BrowserClient import org.koitharu.kotatsu.browser.ProgressChromeClient import org.koitharu.kotatsu.browser.WebViewBackPressedCallback import org.koitharu.kotatsu.core.model.MangaSource +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.network.CommonHeaders import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.parser.ParserMangaRepository @@ -28,6 +27,7 @@ import org.koitharu.kotatsu.databinding.ActivityBrowserBinding import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.model.MangaParserSource import org.koitharu.kotatsu.parsers.model.MangaSource +import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment.Companion.EXTRA_SOURCE import javax.inject.Inject import com.google.android.material.R as materialR @@ -66,7 +66,6 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba setHomeAsUpIndicator(materialR.drawable.abc_ic_clear_material) } viewBinding.webView.configureForParser(repository.getRequestHeaders()[CommonHeaders.USER_AGENT]) - CookieManager.getInstance().setAcceptThirdPartyCookies(viewBinding.webView, true) viewBinding.webView.webViewClient = BrowserClient(this) viewBinding.webView.webChromeClient = ProgressChromeClient(viewBinding.progressBar) onBackPressedCallback = WebViewBackPressedCallback(viewBinding.webView) @@ -90,7 +89,7 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba override fun onOptionsItemSelected(item: MenuItem): Boolean = when (item.itemId) { android.R.id.home -> { viewBinding.webView.stopLoading() - setResult(Activity.RESULT_CANCELED) + setResult(RESULT_CANCELED) finishAfterTransition() true } @@ -112,7 +111,7 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba viewBinding.progressBar.isVisible = isLoading if (!isLoading && authProvider.isAuthorized) { Toast.makeText(this, R.string.auth_complete, Toast.LENGTH_SHORT).show() - setResult(Activity.RESULT_OK) + setResult(RESULT_OK) finishAfterTransition() } } @@ -133,7 +132,7 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba class Contract : ActivityResultContract() { override fun createIntent(context: Context, input: MangaSource): Intent { - return newIntent(context, input) + return AppRouter.sourceAuthIntent(context, input) } override fun parseResult(resultCode: Int, intent: Intent?): Boolean { @@ -142,13 +141,6 @@ class SourceAuthActivity : BaseActivity(), BrowserCallba } companion object { - - private const val EXTRA_SOURCE = "source" const val TAG = "SourceAuthActivity" - - fun newIntent(context: Context, source: MangaSource): Intent { - return Intent(context, SourceAuthActivity::class.java) - .putExtra(EXTRA_SOURCE, source.name) - } } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt index bb4b71200..1fa7bda4a 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/catalog/SourcesCatalogActivity.kt @@ -16,6 +16,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.combine import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.titleResId +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.ui.util.FadingAppbarMediator @@ -31,7 +32,6 @@ import org.koitharu.kotatsu.databinding.ActivitySourcesCatalogBinding import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import org.koitharu.kotatsu.main.ui.owners.AppBarOwner import org.koitharu.kotatsu.parsers.model.ContentType -import org.koitharu.kotatsu.search.ui.MangaListActivity import javax.inject.Inject @AndroidEntryPoint @@ -89,7 +89,7 @@ class SourcesCatalogActivity : BaseActivity(), } override fun onItemClick(item: SourceCatalogItem.Source, view: View) { - startActivity(MangaListActivity.newIntent(this, item.source, null)) + router.openList(item.source, null) } override fun onItemLongClick(item: SourceCatalogItem.Source, view: View): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt index 7df5129f6..c10147f1f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesListProducer.kt @@ -70,6 +70,7 @@ class SourcesListProducer @Inject constructor( val pinned = repository.getPinnedSources().mapToSet { it.name } val isNsfwDisabled = settings.isNsfwContentDisabled val isReorderAvailable = settings.sourcesSortOrder == SourcesSortOrder.MANUAL + val isDisableAvailable = !settings.isAllSourcesEnabled val withTip = isReorderAvailable && settings.isTipEnabled(TIP_REORDER) val enabledSet = enabledSources.toSet() if (query.isNotEmpty()) { @@ -83,6 +84,7 @@ class SourcesListProducer @Inject constructor( isDraggable = false, isAvailable = !isNsfwDisabled || !it.isNsfw(), isPinned = it.name in pinned, + isDisableAvailable = isDisableAvailable, ) }.ifEmpty { listOf(SourceConfigItem.EmptySearchResult) @@ -104,6 +106,7 @@ class SourcesListProducer @Inject constructor( isDraggable = isReorderAvailable, isAvailable = false, isPinned = it.name in pinned, + isDisableAvailable = isDisableAvailable, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt index 07bcc8921..daf6c98af 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/manage/SourcesManageFragment.kt @@ -1,6 +1,5 @@ package org.koitharu.kotatsu.settings.sources.manage -import android.content.Intent import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -18,6 +17,7 @@ import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.launch import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.ui.BaseFragment @@ -34,7 +34,6 @@ import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.sources.SourceSettingsFragment import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigAdapter import org.koitharu.kotatsu.settings.sources.adapter.SourceConfigListener -import org.koitharu.kotatsu.settings.sources.catalog.SourcesCatalogActivity import org.koitharu.kotatsu.settings.sources.model.SourceConfigItem import javax.inject.Inject @@ -152,7 +151,7 @@ class SourcesManageFragment : override fun onMenuItemSelected(menuItem: MenuItem): Boolean = when (menuItem.itemId) { R.id.action_catalog -> { - startActivity(Intent(context, SourcesCatalogActivity::class.java)) + router.openSourcesCatalog() true } @@ -172,6 +171,8 @@ class SourcesManageFragment : override fun onPrepareMenu(menu: Menu) { super.onPrepareMenu(menu) menu.findItem(R.id.action_no_nsfw).isChecked = settings.isNsfwContentDisabled + menu.findItem(R.id.action_disable_all).isVisible = !settings.isAllSourcesEnabled + menu.findItem(R.id.action_catalog).isVisible = !settings.isAllSourcesEnabled } override fun onMenuItemActionExpand(item: MenuItem): Boolean { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt index 321a54334..2c014f07e 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/sources/model/SourceConfigItem.kt @@ -14,6 +14,7 @@ sealed interface SourceConfigItem : ListModel { val isDraggable: Boolean, val isAvailable: Boolean, val isPinned: Boolean, + val isDisableAvailable: Boolean, ) : SourceConfigItem { val isNsfw: Boolean diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt index f9294c3e0..e7fd70bbf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/storage/MangaDirectorySelectDialog.kt @@ -1,6 +1,7 @@ package org.koitharu.kotatsu.settings.storage import android.Manifest +import android.content.Intent import android.os.Build import android.os.Bundle import android.view.LayoutInflater @@ -8,18 +9,17 @@ import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.hannesdorfmann.adapterdelegates4.AsyncListDifferDelegationAdapter import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.ToastErrorObserver +import org.koitharu.kotatsu.core.os.OpenDocumentTreeHelper import org.koitharu.kotatsu.core.ui.AlertDialogFragment import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.tryLaunch import org.koitharu.kotatsu.databinding.DialogDirectorySelectBinding @@ -28,7 +28,12 @@ class MangaDirectorySelectDialog : AlertDialogFragment { private val viewModel: MangaDirectorySelectViewModel by viewModels() - private val pickFileTreeLauncher = registerForActivityResult(PickDirectoryContract()) { + private val pickFileTreeLauncher = OpenDocumentTreeHelper( + activityResultCaller = this, + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION, + ) { if (it != null) viewModel.onCustomDirectoryPicked(it) } private val permissionRequestLauncher = registerForActivityResult( @@ -80,12 +85,4 @@ class MangaDirectorySelectDialog : AlertDialogFragment() OnListItemClickListener, View.OnClickListener { private val viewModel: MangaDirectoriesViewModel by viewModels() - private val pickFileTreeLauncher = registerForActivityResult(PickDirectoryContract()) { + private val pickFileTreeLauncher = OpenDocumentTreeHelper( + activityResultCaller = this, + flags = Intent.FLAG_GRANT_READ_URI_PERMISSION + or Intent.FLAG_GRANT_WRITE_URI_PERMISSION + or Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION, + ) { if (it != null) viewModel.onCustomDirectoryPicked(it) } private val permissionRequestLauncher = registerForActivityResult( @@ -97,9 +101,4 @@ class MangaDirectoriesActivity : BaseActivity() bottom = insets.bottom, ) } - - companion object { - - fun newIntent(context: Context) = Intent(context, MangaDirectoriesActivity::class.java) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt index cb0ccd202..08105be06 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/TrackerSettingsFragment.kt @@ -1,5 +1,6 @@ package org.koitharu.kotatsu.settings.tracker +import android.content.ActivityNotFoundException import android.content.Intent import android.content.SharedPreferences import android.net.Uri @@ -14,15 +15,16 @@ import androidx.fragment.app.viewModels import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference +import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy import org.koitharu.kotatsu.core.ui.BasePreferenceFragment import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.tracker.categories.TrackerCategoriesConfigSheet import org.koitharu.kotatsu.settings.utils.DozeHelper import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import org.koitharu.kotatsu.tracker.ui.debug.TrackerDebugActivity @@ -108,7 +110,7 @@ class TrackerSettingsFragment : } AppSettings.KEY_TRACK_CATEGORIES -> { - TrackerCategoriesConfigSheet.show(childFragmentManager) + router.showTrackerCategoriesConfigSheet() true } @@ -147,4 +149,12 @@ class TrackerSettingsFragment : getString(R.string.enabled_d_of_d, count[0], count[1]) } } + + private fun startActivitySafe(intent: Intent): Boolean = try { + startActivity(intent) + true + } catch (_: ActivityNotFoundException) { + Snackbar.make(listView, R.string.operation_not_supported, Snackbar.LENGTH_SHORT).show() + false + } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt index 3e0a07e34..d32a578cb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/tracker/categories/TrackerCategoriesConfigSheet.kt @@ -4,7 +4,6 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R @@ -37,11 +36,4 @@ class TrackerCategoriesConfigSheet : override fun onItemClick(item: FavouriteCategory, view: View) { viewModel.toggleItem(item) } - - companion object { - - private const val TAG = "TrackerCategoriesConfigSheet" - - fun show(fm: FragmentManager) = TrackerCategoriesConfigSheet().show(fm, TAG) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt index e85a1beee..2c9863100 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsFragment.kt @@ -7,39 +7,28 @@ import android.os.Bundle import android.view.View import androidx.activity.result.ActivityResultCallback import androidx.activity.result.contract.ActivityResultContracts -import androidx.appcompat.app.AppCompatDelegate -import androidx.collection.ArraySet -import androidx.core.view.postDelayed import androidx.fragment.app.viewModels import androidx.preference.ListPreference import androidx.preference.MultiSelectListPreference import androidx.preference.Preference import androidx.preference.TwoStatePreference -import androidx.preference.forEach -import androidx.preference.get -import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.snackbar.Snackbar import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.flow.StateFlow import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.os.AppShortcutManager import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.ScreenshotsPolicy import org.koitharu.kotatsu.core.prefs.SearchSuggestionType import org.koitharu.kotatsu.core.ui.BasePreferenceFragment -import org.koitharu.kotatsu.core.ui.util.ActivityRecreationHandle -import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver import org.koitharu.kotatsu.core.util.FileSize import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.core.util.ext.setDefaultValueCompat import org.koitharu.kotatsu.core.util.ext.tryLaunch -import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.parsers.util.mapToSet import org.koitharu.kotatsu.parsers.util.names -import org.koitharu.kotatsu.settings.backup.BackupDialogFragment -import org.koitharu.kotatsu.settings.backup.RestoreDialogFragment import org.koitharu.kotatsu.settings.protect.ProtectSetupActivity import org.koitharu.kotatsu.settings.utils.MultiSummaryProvider import javax.inject.Inject @@ -52,11 +41,7 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac @Inject lateinit var appShortcutManager: AppShortcutManager - @Inject - lateinit var activityRecreationHandle: ActivityRecreationHandle - private val viewModel: UserDataSettingsViewModel by viewModels() - private val loadingPrefs = HashSet() private val backupSelectCall = registerForActivityResult( ActivityResultContracts.OpenDocument(), @@ -77,46 +62,23 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) - findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) - findPreference(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) bindPeriodicalBackupSummary() - findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> - viewModel.searchHistoryCount.observe(viewLifecycleOwner) { - pref.summary = if (it < 0) { - view.context.getString(R.string.loading_) - } else { - pref.context.resources.getQuantityString(R.plurals.items, it, it) - } - } - } - findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> - viewModel.feedItemsCount.observe(viewLifecycleOwner) { - pref.summary = if (it < 0) { - view.context.getString(R.string.loading_) - } else { - pref.context.resources.getQuantityString(R.plurals.items, it, it) - } - } - } - findPreference("storage_usage")?.let { pref -> - viewModel.storageUsage.observe(viewLifecycleOwner, pref) - } findPreference(AppSettings.KEY_SEARCH_SUGGESTION_TYPES)?.let { pref -> pref.entryValues = SearchSuggestionType.entries.names() pref.entries = SearchSuggestionType.entries.map { pref.context.getString(it.titleResId) }.toTypedArray() pref.summaryProvider = MultiSummaryProvider(R.string.none) pref.values = settings.searchSuggestionTypes.mapToSet { it.name } } - viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> - loadingPrefs.addAll(keys) - loadingPrefs.forEach { prefKey -> - findPreference(prefKey)!!.isEnabled = prefKey !in keys + findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> + viewModel.storageUsage.observe(viewLifecycleOwner) { size -> + pref.summary = if (size < 0L) { + pref.context.getString(R.string.computing_) + } else { + FileSize.BYTES.format(pref.context, size) + } } } viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) - viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) - viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp) settings.subscribe(this) } @@ -127,43 +89,8 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac override fun onPreferenceTreeClick(preference: Preference): Boolean { return when (preference.key) { - AppSettings.KEY_PAGES_CACHE_CLEAR -> { - viewModel.clearCache(preference.key, CacheDir.PAGES) - true - } - - AppSettings.KEY_THUMBS_CACHE_CLEAR -> { - viewModel.clearCache(preference.key, CacheDir.THUMBS) - true - } - - AppSettings.KEY_COOKIES_CLEAR -> { - clearCookies() - true - } - - AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { - clearSearchHistory() - true - } - - AppSettings.KEY_HTTP_CACHE_CLEAR -> { - viewModel.clearHttpCache() - true - } - - AppSettings.KEY_CHAPTERS_CLEAR -> { - cleanupChapters() - true - } - - AppSettings.KEY_UPDATES_FEED_CLEAR -> { - viewModel.clearUpdatesFeed() - true - } - AppSettings.KEY_BACKUP -> { - BackupDialogFragment.show(childFragmentManager) + router.showBackupCreateDialog() true } @@ -197,50 +124,12 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac findPreference(AppSettings.KEY_PROTECT_APP) ?.isChecked = !settings.appPassword.isNullOrEmpty() } - - AppSettings.KEY_THEME -> { - AppCompatDelegate.setDefaultNightMode(settings.theme) - } - - AppSettings.KEY_COLOR_THEME, - AppSettings.KEY_THEME_AMOLED -> { - postRestart() - } - - AppSettings.KEY_APP_LOCALE -> { - AppCompatDelegate.setApplicationLocales(settings.appLocales) - } } } override fun onActivityResult(result: Uri?) { if (result != null) { - RestoreDialogFragment.show(childFragmentManager, result) - } - } - - private fun onChaptersCleanedUp(result: Pair) { - val c = context ?: return - val text = if (result.first == 0 && result.second == 0L) { - c.getString(R.string.no_chapters_deleted) - } else { - c.getString( - R.string.chapters_deleted_pattern, - c.resources.getQuantityString(R.plurals.chapters, result.first, result.first), - FileSize.BYTES.format(c, result.second), - ) - } - Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show() - } - - - private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow) { - stateFlow.observe(viewLifecycleOwner) { size -> - summary = if (size < 0) { - context.getString(R.string.computing_) - } else { - FileSize.BYTES.format(context, size) - } + router.showBackupRestoreDialog(result) } } @@ -257,41 +146,4 @@ class UserDataSettingsFragment : BasePreferenceFragment(R.string.data_and_privac } } } - - private fun clearSearchHistory() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.clear_search_history) - .setMessage(R.string.text_clear_search_history_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.clear) { _, _ -> - viewModel.clearSearchHistory() - }.show() - } - - private fun clearCookies() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.clear_cookies) - .setMessage(R.string.text_clear_cookies_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.clear) { _, _ -> - viewModel.clearCookies() - }.show() - } - - private fun cleanupChapters() { - MaterialAlertDialogBuilder(context ?: return) - .setTitle(R.string.delete_read_chapters) - .setMessage(R.string.delete_read_chapters_prompt) - .setNegativeButton(android.R.string.cancel, null) - .setPositiveButton(R.string.delete) { _, _ -> - viewModel.cleanupChapters() - }.show() - } - - private fun postRestart() { - view?.postDelayed(400) { - activityRecreationHandle.recreateAll() - } - } - } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt index ed87944c0..75b57ad44 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/UserDataSettingsViewModel.kt @@ -4,51 +4,22 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancelAndJoin -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.update -import kotlinx.coroutines.runInterruptible -import okhttp3.Cache -import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.observeAsFlow import org.koitharu.kotatsu.core.ui.BaseViewModel -import org.koitharu.kotatsu.core.ui.util.ReversibleAction -import org.koitharu.kotatsu.core.util.ext.MutableEventFlow -import org.koitharu.kotatsu.core.util.ext.call -import org.koitharu.kotatsu.core.util.ext.firstNotNull -import org.koitharu.kotatsu.local.data.CacheDir import org.koitharu.kotatsu.local.data.LocalStorageManager -import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase -import org.koitharu.kotatsu.search.domain.MangaSearchRepository -import org.koitharu.kotatsu.tracker.domain.TrackingRepository -import java.util.EnumMap import javax.inject.Inject @HiltViewModel class UserDataSettingsViewModel @Inject constructor( private val storageManager: LocalStorageManager, - private val httpCache: Cache, - private val searchRepository: MangaSearchRepository, - private val trackingRepository: TrackingRepository, - private val cookieJar: MutableCookieJar, private val settings: AppSettings, - private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, ) : BaseViewModel() { - val onActionDone = MutableEventFlow() - val loadingKeys = MutableStateFlow(emptySet()) - - val searchHistoryCount = MutableStateFlow(-1) - val feedItemsCount = MutableStateFlow(-1) - val httpCacheSize = MutableStateFlow(-1L) - val cacheSizes = EnumMap>(CacheDir::class.java) - val storageUsage = MutableStateFlow(null) - - val onChaptersCleanedUp = MutableEventFlow>() + val storageUsage = MutableStateFlow(-1L) val periodicalBackupFrequency = settings.observeAsFlow( key = AppSettings.KEY_BACKUP_PERIODICAL_ENABLED, @@ -67,120 +38,15 @@ class UserDataSettingsViewModel @Inject constructor( private var storageUsageJob: Job? = null init { - CacheDir.entries.forEach { - cacheSizes[it] = MutableStateFlow(-1L) - } - launchJob(Dispatchers.Default) { - searchHistoryCount.value = searchRepository.getSearchHistoryCount() - } - launchJob(Dispatchers.Default) { - feedItemsCount.value = trackingRepository.getLogsCount() - } - CacheDir.entries.forEach { cache -> - launchJob(Dispatchers.Default) { - checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) - } - } - launchJob(Dispatchers.Default) { - httpCacheSize.value = runInterruptible { httpCache.size() } - } loadStorageUsage() } - fun clearCache(key: String, cache: CacheDir) { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + key } - storageManager.clearCache(cache) - checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) - loadStorageUsage() - } finally { - loadingKeys.update { it - key } - } - } - } - - fun clearHttpCache() { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR } - val size = runInterruptible(Dispatchers.IO) { - httpCache.evictAll() - httpCache.size() - } - httpCacheSize.value = size - loadStorageUsage() - } finally { - loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR } - } - } - } - - fun clearSearchHistory() { - launchJob(Dispatchers.Default) { - searchRepository.clearSearchHistory() - searchHistoryCount.value = searchRepository.getSearchHistoryCount() - onActionDone.call(ReversibleAction(R.string.search_history_cleared, null)) - } - } - - fun clearCookies() { - launchJob { - cookieJar.clear() - onActionDone.call(ReversibleAction(R.string.cookies_cleared, null)) - } - } - - fun clearUpdatesFeed() { - launchJob(Dispatchers.Default) { - trackingRepository.clearLogs() - feedItemsCount.value = trackingRepository.getLogsCount() - onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) - } - } - - fun cleanupChapters() { - launchJob(Dispatchers.Default) { - try { - loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR } - val oldSize = storageUsage.firstNotNull().savedManga.bytes - val chaptersCount = deleteReadChaptersUseCase.invoke() - loadStorageUsage().join() - val newSize = storageUsage.firstNotNull().savedManga.bytes - onChaptersCleanedUp.call(chaptersCount to oldSize - newSize) - } finally { - loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR } - } - } - } - private fun loadStorageUsage(): Job { val prevJob = storageUsageJob return launchJob(Dispatchers.Default) { prevJob?.cancelAndJoin() - val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES) - val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize - val storageSize = storageManager.computeStorageSize() - val availableSpace = storageManager.computeAvailableSize() - val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace - storageUsage.value = StorageUsage( - savedManga = StorageUsage.Item( - bytes = storageSize, - percent = (storageSize.toDouble() / totalBytes).toFloat(), - ), - pagesCache = StorageUsage.Item( - bytes = pagesCacheSize, - percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(), - ), - otherCache = StorageUsage.Item( - bytes = otherCacheSize, - percent = (otherCacheSize.toDouble() / totalBytes).toFloat(), - ), - available = StorageUsage.Item( - bytes = availableSpace, - percent = (availableSpace.toDouble() / totalBytes).toFloat(), - ), - ) + val totalBytes = storageManager.computeCacheSize() + storageManager.computeStorageSize() + storageUsage.value = totalBytes }.also { storageUsageJob = it } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt new file mode 100644 index 000000000..d4dac487d --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsFragment.kt @@ -0,0 +1,167 @@ +package org.koitharu.kotatsu.settings.userdata.storage + +import android.os.Bundle +import android.view.View +import androidx.fragment.app.viewModels +import androidx.preference.Preference +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import com.google.android.material.snackbar.Snackbar +import dagger.hilt.android.AndroidEntryPoint +import kotlinx.coroutines.flow.StateFlow +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BasePreferenceFragment +import org.koitharu.kotatsu.core.ui.util.ReversibleActionObserver +import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.ext.observe +import org.koitharu.kotatsu.core.util.ext.observeEvent +import org.koitharu.kotatsu.local.data.CacheDir + +@AndroidEntryPoint +class StorageManageSettingsFragment : BasePreferenceFragment(R.string.storage_usage) { + + private val viewModel by viewModels() + private val loadingPrefs = HashSet() + + override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) { + addPreferencesFromResource(R.xml.pref_storage) + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + findPreference(AppSettings.KEY_PAGES_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.PAGES])) + findPreference(AppSettings.KEY_THUMBS_CACHE_CLEAR)?.bindBytesSizeSummary(checkNotNull(viewModel.cacheSizes[CacheDir.THUMBS])) + findPreference(AppSettings.KEY_HTTP_CACHE_CLEAR)?.bindBytesSizeSummary(viewModel.httpCacheSize) + findPreference(AppSettings.KEY_SEARCH_HISTORY_CLEAR)?.let { pref -> + viewModel.searchHistoryCount.observe(viewLifecycleOwner) { + pref.summary = if (it < 0) { + view.context.getString(R.string.loading_) + } else { + pref.context.resources.getQuantityString(R.plurals.items, it, it) + } + } + } + findPreference(AppSettings.KEY_UPDATES_FEED_CLEAR)?.let { pref -> + viewModel.feedItemsCount.observe(viewLifecycleOwner) { + pref.summary = if (it < 0) { + view.context.getString(R.string.loading_) + } else { + pref.context.resources.getQuantityString(R.plurals.items, it, it) + } + } + } + findPreference(AppSettings.KEY_STORAGE_USAGE)?.let { pref -> + viewModel.storageUsage.observe(viewLifecycleOwner, pref) + } + + viewModel.loadingKeys.observe(viewLifecycleOwner) { keys -> + loadingPrefs.addAll(keys) + loadingPrefs.forEach { prefKey -> + findPreference(prefKey)?.isEnabled = prefKey !in keys + } + } + viewModel.onError.observeEvent(viewLifecycleOwner, SnackbarErrorObserver(listView, this)) + viewModel.onActionDone.observeEvent(viewLifecycleOwner, ReversibleActionObserver(listView)) + viewModel.onChaptersCleanedUp.observeEvent(viewLifecycleOwner, ::onChaptersCleanedUp) + } + + override fun onPreferenceTreeClick(preference: Preference): Boolean = when (preference.key) { + AppSettings.KEY_COOKIES_CLEAR -> { + clearCookies() + true + } + + AppSettings.KEY_SEARCH_HISTORY_CLEAR -> { + clearSearchHistory() + true + } + + AppSettings.KEY_PAGES_CACHE_CLEAR -> { + viewModel.clearCache(preference.key, CacheDir.PAGES) + true + } + + AppSettings.KEY_THUMBS_CACHE_CLEAR -> { + viewModel.clearCache(preference.key, CacheDir.THUMBS) + true + } + + AppSettings.KEY_HTTP_CACHE_CLEAR -> { + viewModel.clearHttpCache() + true + } + + AppSettings.KEY_CHAPTERS_CLEAR -> { + cleanupChapters() + true + } + + + AppSettings.KEY_CLEAR_MANGA_DATA -> { + viewModel.clearMangaData() + true + } + + AppSettings.KEY_UPDATES_FEED_CLEAR -> { + viewModel.clearUpdatesFeed() + true + } + + else -> super.onPreferenceTreeClick(preference) + } + + private fun onChaptersCleanedUp(result: Pair) { + val c = context ?: return + val text = if (result.first == 0 && result.second == 0L) { + c.getString(R.string.no_chapters_deleted) + } else { + c.getString( + R.string.chapters_deleted_pattern, + c.resources.getQuantityString(R.plurals.chapters, result.first, result.first), + FileSize.BYTES.format(c, result.second), + ) + } + Snackbar.make(listView, text, Snackbar.LENGTH_SHORT).show() + } + + private fun Preference.bindBytesSizeSummary(stateFlow: StateFlow) { + stateFlow.observe(viewLifecycleOwner) { size -> + summary = if (size < 0) { + context.getString(R.string.computing_) + } else { + FileSize.BYTES.format(context, size) + } + } + } + + private fun clearSearchHistory() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.clear_search_history) + .setMessage(R.string.text_clear_search_history_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearSearchHistory() + }.show() + } + + private fun clearCookies() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.clear_cookies) + .setMessage(R.string.text_clear_cookies_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.clear) { _, _ -> + viewModel.clearCookies() + }.show() + } + + private fun cleanupChapters() { + MaterialAlertDialogBuilder(context ?: return) + .setTitle(R.string.delete_read_chapters) + .setMessage(R.string.delete_read_chapters_prompt) + .setNegativeButton(android.R.string.cancel, null) + .setPositiveButton(R.string.delete) { _, _ -> + viewModel.cleanupChapters() + }.show() + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt new file mode 100644 index 000000000..6ab399c35 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageManageSettingsViewModel.kt @@ -0,0 +1,187 @@ +package org.koitharu.kotatsu.settings.userdata.storage + +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.runInterruptible +import okhttp3.Cache +import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.network.cookies.MutableCookieJar +import org.koitharu.kotatsu.core.parser.MangaDataRepository +import org.koitharu.kotatsu.core.prefs.AppSettings +import org.koitharu.kotatsu.core.ui.BaseViewModel +import org.koitharu.kotatsu.core.ui.util.ReversibleAction +import org.koitharu.kotatsu.core.util.ext.MutableEventFlow +import org.koitharu.kotatsu.core.util.ext.call +import org.koitharu.kotatsu.core.util.ext.firstNotNull +import org.koitharu.kotatsu.local.data.CacheDir +import org.koitharu.kotatsu.local.data.LocalStorageManager +import org.koitharu.kotatsu.local.domain.DeleteReadChaptersUseCase +import org.koitharu.kotatsu.search.domain.MangaSearchRepository +import org.koitharu.kotatsu.tracker.domain.TrackingRepository +import java.util.EnumMap +import javax.inject.Inject +import javax.inject.Provider + +@HiltViewModel +class StorageManageSettingsViewModel @Inject constructor( + private val storageManager: LocalStorageManager, + private val httpCache: Cache, + private val searchRepository: MangaSearchRepository, + private val trackingRepository: TrackingRepository, + private val cookieJar: MutableCookieJar, + private val deleteReadChaptersUseCase: DeleteReadChaptersUseCase, + private val mangaDataRepositoryProvider: Provider, +) : BaseViewModel() { + + val onActionDone = MutableEventFlow() + val loadingKeys = MutableStateFlow(emptySet()) + + val searchHistoryCount = MutableStateFlow(-1) + val feedItemsCount = MutableStateFlow(-1) + val httpCacheSize = MutableStateFlow(-1L) + val cacheSizes = EnumMap>(CacheDir::class.java) + val storageUsage = MutableStateFlow(null) + + val onChaptersCleanedUp = MutableEventFlow>() + + private var storageUsageJob: Job? = null + + init { + CacheDir.entries.forEach { + cacheSizes[it] = MutableStateFlow(-1L) + } + launchJob(Dispatchers.Default) { + searchHistoryCount.value = searchRepository.getSearchHistoryCount() + } + launchJob(Dispatchers.Default) { + feedItemsCount.value = trackingRepository.getLogsCount() + } + CacheDir.entries.forEach { cache -> + launchJob(Dispatchers.Default) { + checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) + } + } + launchJob(Dispatchers.Default) { + httpCacheSize.value = runInterruptible { httpCache.size() } + } + loadStorageUsage() + } + + fun clearCache(key: String, cache: CacheDir) { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + key } + storageManager.clearCache(cache) + checkNotNull(cacheSizes[cache]).value = storageManager.computeCacheSize(cache) + loadStorageUsage() + } finally { + loadingKeys.update { it - key } + } + } + } + + fun clearHttpCache() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_HTTP_CACHE_CLEAR } + val size = runInterruptible(Dispatchers.IO) { + httpCache.evictAll() + httpCache.size() + } + httpCacheSize.value = size + loadStorageUsage() + } finally { + loadingKeys.update { it - AppSettings.KEY_HTTP_CACHE_CLEAR } + } + } + } + + fun clearSearchHistory() { + launchJob(Dispatchers.Default) { + searchRepository.clearSearchHistory() + searchHistoryCount.value = searchRepository.getSearchHistoryCount() + onActionDone.call(ReversibleAction(R.string.search_history_cleared, null)) + } + } + + fun clearCookies() { + launchJob { + cookieJar.clear() + onActionDone.call(ReversibleAction(R.string.cookies_cleared, null)) + } + } + + fun clearUpdatesFeed() { + launchJob(Dispatchers.Default) { + trackingRepository.clearLogs() + feedItemsCount.value = trackingRepository.getLogsCount() + onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) + } + } + + fun clearMangaData() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_CLEAR_MANGA_DATA } + trackingRepository.gc() + val repository = mangaDataRepositoryProvider.get() + repository.cleanupLocalManga() + repository.cleanupDatabase() + onActionDone.call(ReversibleAction(R.string.updates_feed_cleared, null)) + } finally { + loadingKeys.update { it - AppSettings.KEY_CLEAR_MANGA_DATA } + } + } + } + + fun cleanupChapters() { + launchJob(Dispatchers.Default) { + try { + loadingKeys.update { it + AppSettings.KEY_CHAPTERS_CLEAR } + val oldSize = storageUsage.firstNotNull().savedManga.bytes + val chaptersCount = deleteReadChaptersUseCase.invoke() + loadStorageUsage().join() + val newSize = storageUsage.firstNotNull().savedManga.bytes + onChaptersCleanedUp.call(chaptersCount to oldSize - newSize) + } finally { + loadingKeys.update { it - AppSettings.KEY_CHAPTERS_CLEAR } + } + } + } + + private fun loadStorageUsage(): Job { + val prevJob = storageUsageJob + return launchJob(Dispatchers.Default) { + prevJob?.cancelAndJoin() + val pagesCacheSize = storageManager.computeCacheSize(CacheDir.PAGES) + val otherCacheSize = storageManager.computeCacheSize() - pagesCacheSize + val storageSize = storageManager.computeStorageSize() + val availableSpace = storageManager.computeAvailableSize() + val totalBytes = pagesCacheSize + otherCacheSize + storageSize + availableSpace + storageUsage.value = StorageUsage( + savedManga = StorageUsage.Item( + bytes = storageSize, + percent = (storageSize.toDouble() / totalBytes).toFloat(), + ), + pagesCache = StorageUsage.Item( + bytes = pagesCacheSize, + percent = (pagesCacheSize.toDouble() / totalBytes).toFloat(), + ), + otherCache = StorageUsage.Item( + bytes = otherCacheSize, + percent = (otherCacheSize.toDouble() / totalBytes).toFloat(), + ), + available = StorageUsage.Item( + bytes = availableSpace, + percent = (availableSpace.toDouble() / totalBytes).toFloat(), + ), + ) + }.also { + storageUsageJob = it + } + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt similarity index 77% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt index 6ed2e4748..2b70279c2 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsage.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsage.kt @@ -1,4 +1,4 @@ -package org.koitharu.kotatsu.settings.userdata +package org.koitharu.kotatsu.settings.userdata.storage data class StorageUsage( val savedManga: Item, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt similarity index 89% rename from app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt rename to app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt index 6f12d7069..6e993f580 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/StorageUsagePreference.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/userdata/storage/StorageUsagePreference.kt @@ -1,7 +1,8 @@ -package org.koitharu.kotatsu.settings.userdata +package org.koitharu.kotatsu.settings.userdata.storage import android.content.Context import android.content.res.ColorStateList +import android.graphics.Color import android.util.AttributeSet import androidx.annotation.StringRes import androidx.core.widget.TextViewCompat @@ -10,10 +11,9 @@ import androidx.preference.PreferenceViewHolder import kotlinx.coroutines.flow.FlowCollector import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.widgets.SegmentedBarView -import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.FileSize +import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.databinding.PreferenceMemoryUsageBinding -import com.google.android.material.R as materialR class StorageUsagePreference @JvmOverloads constructor( context: Context, @@ -34,15 +34,15 @@ class StorageUsagePreference @JvmOverloads constructor( val binding = PreferenceMemoryUsageBinding.bind(holder.itemView) val storageSegment = SegmentedBarView.Segment( usage?.savedManga?.percent ?: 0f, - KotatsuColors.segmentColor(context, materialR.attr.colorPrimary), + KotatsuColors.segmentColorRandom(context, Color.BLUE), ) val pagesSegment = SegmentedBarView.Segment( usage?.pagesCache?.percent ?: 0f, - KotatsuColors.segmentColor(context, materialR.attr.colorSecondary), + KotatsuColors.segmentColorRandom(context, Color.GREEN), ) val otherSegment = SegmentedBarView.Segment( usage?.otherCache?.percent ?: 0f, - KotatsuColors.segmentColor(context, materialR.attr.colorTertiary), + KotatsuColors.segmentColorRandom(context, Color.GRAY), ) with(binding) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt index 851ede594..3e2ca6509 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextDefaultSummaryProvider.kt @@ -3,17 +3,15 @@ package org.koitharu.kotatsu.settings.utils import androidx.preference.EditTextPreference import androidx.preference.Preference import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty class EditTextDefaultSummaryProvider( - private val defaultValue: String + private val defaultValue: String, ) : Preference.SummaryProvider { - override fun provideSummary(preference: EditTextPreference): CharSequence { - val text = preference.text - return if (text.isNullOrEmpty()) { - preference.context.getString(R.string.default_s, defaultValue) - } else { - text - } + override fun provideSummary( + preference: EditTextPreference, + ): CharSequence = preference.text.ifNullOrEmpty { + preference.context.getString(R.string.default_s, defaultValue) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt new file mode 100644 index 000000000..a57f405e1 --- /dev/null +++ b/app/src/main/kotlin/org/koitharu/kotatsu/settings/utils/EditTextFallbackSummaryProvider.kt @@ -0,0 +1,17 @@ +package org.koitharu.kotatsu.settings.utils + +import androidx.annotation.StringRes +import androidx.preference.EditTextPreference +import androidx.preference.Preference +import org.koitharu.kotatsu.parsers.util.ifNullOrEmpty + +class EditTextFallbackSummaryProvider( + @StringRes private val fallbackResId: Int, +) : Preference.SummaryProvider { + + override fun provideSummary( + preference: EditTextPreference, + ): CharSequence = preference.text.ifNullOrEmpty { + preference.context.getString(fallbackResId) + } +} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt index 9b621e1b0..54992260c 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/data/StatsRepository.kt @@ -33,7 +33,7 @@ class StatsRepository @Inject constructor( var other = StatsRecord(null, 0) val total = stats.values.sum() for ((mangaEntity, duration) in stats) { - val manga = mangaEntity.toManga(emptySet()) + val manga = mangaEntity.toManga(emptySet(), null) val percent = duration.toDouble() / total if (percent < 0.05) { other = other.copy(duration = other.duration + duration) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt index 141e9e487..7da0dfa9b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/StatsActivity.kt @@ -18,6 +18,7 @@ import com.google.android.material.chip.ChipDrawable import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.model.FavouriteCategory +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.dialog.buildAlertDialog @@ -36,7 +37,6 @@ import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.stats.domain.StatsPeriod import org.koitharu.kotatsu.stats.domain.StatsRecord -import org.koitharu.kotatsu.stats.ui.sheet.MangaStatsSheet import org.koitharu.kotatsu.stats.ui.views.PieChartView import javax.inject.Inject @@ -103,7 +103,7 @@ class StatsActivity : BaseActivity(), } override fun onItemClick(item: Manga, view: View) { - MangaStatsSheet.show(supportFragmentManager, item) + router.showStatisticSheet(item) } override fun onSegmentClick(view: PieChartView, segment: PieChartView.Segment) { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt index 7c12c195c..8c001b9c4 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsSheet.kt @@ -5,20 +5,15 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import androidx.collection.IntList -import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.sheet.BaseAdaptiveSheet import org.koitharu.kotatsu.core.util.KotatsuColors import org.koitharu.kotatsu.core.util.ext.observe -import org.koitharu.kotatsu.core.util.ext.showDistinct import org.koitharu.kotatsu.core.util.ext.textAndVisible -import org.koitharu.kotatsu.core.util.ext.withArgs import org.koitharu.kotatsu.databinding.SheetStatsMangaBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity -import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.util.format import org.koitharu.kotatsu.stats.ui.views.BarChartView @@ -37,7 +32,7 @@ class MangaStatsSheet : BaseAdaptiveSheet(), View.OnClic binding.chartView.barColor = KotatsuColors.ofManga(binding.root.context, viewModel.manga) viewModel.stats.observe(viewLifecycleOwner, ::onStatsChanged) viewModel.startDate.observe(viewLifecycleOwner) { - binding.textViewStart.textAndVisible = it?.format(resources) + binding.textViewStart.textAndVisible = it?.format(binding.root.context) } viewModel.totalPagesRead.observe(viewLifecycleOwner) { binding.textViewPages.text = getString(R.string.pages_read_s, it.format()) @@ -46,7 +41,7 @@ class MangaStatsSheet : BaseAdaptiveSheet(), View.OnClic } override fun onClick(v: View) { - startActivity(DetailsActivity.newIntent(v.context, viewModel.manga)) + router.openDetails(viewModel.manga) } private fun onStatsChanged(stats: IntList) { @@ -66,17 +61,4 @@ class MangaStatsSheet : BaseAdaptiveSheet(), View.OnClic } chartView.setData(bars) } - - companion object { - - const val ARG_MANGA = "manga" - - private const val TAG = "MangaStatsSheet" - - fun show(fm: FragmentManager, manga: Manga) { - MangaStatsSheet().withArgs(1) { - putParcelable(ARG_MANGA, ParcelableManga(manga)) - }.showDistinct(fm, TAG) - } - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt index 9fc1f782b..7d0133608 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/stats/ui/sheet/MangaStatsViewModel.kt @@ -8,6 +8,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow import org.koitharu.kotatsu.core.model.parcelable.ParcelableManga +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.ui.BaseViewModel import org.koitharu.kotatsu.core.ui.model.DateTimeAgo import org.koitharu.kotatsu.core.util.ext.calculateTimeAgo @@ -23,7 +24,7 @@ class MangaStatsViewModel @Inject constructor( private val repository: StatsRepository, ) : BaseViewModel() { - val manga = savedStateHandle.require(MangaStatsSheet.ARG_MANGA).manga + val manga = savedStateHandle.require(AppRouter.KEY_MANGA).manga val stats = MutableStateFlow(emptyIntList()) val startDate = MutableStateFlow(null) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt index b7f8a4e6a..6a2e168cf 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/domain/SuggestionRepository.kt @@ -15,6 +15,7 @@ import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaSource import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.suggestions.data.SuggestionEntity +import org.koitharu.kotatsu.suggestions.data.SuggestionWithManga import javax.inject.Inject class SuggestionRepository @Inject constructor( @@ -23,25 +24,23 @@ class SuggestionRepository @Inject constructor( fun observeAll(): Flow> { return db.getSuggestionDao().observeAll().mapItems { - it.manga.toManga(it.tags.toMangaTags()) + it.toManga() } } fun observeAll(limit: Int, filterOptions: Set): Flow> { return db.getSuggestionDao().observeAll(limit, filterOptions).mapItems { - it.manga.toManga(it.tags.toMangaTags()) + it.toManga() } } suspend fun getRandom(): Manga? { - return db.getSuggestionDao().getRandom()?.let { - it.manga.toManga(it.tags.toMangaTags()) - } + return db.getSuggestionDao().getRandom()?.toManga() } suspend fun getRandomList(limit: Int): List { return db.getSuggestionDao().getRandom(limit).map { - it.manga.toManga(it.tags.toMangaTags()) + it.toManga() } } @@ -80,4 +79,6 @@ class SuggestionRepository @Inject constructor( } } } + + private fun SuggestionWithManga.toManga() = manga.toManga(tags.toMangaTags(), null) } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt index 207091960..890ebfa87 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsActivity.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.suggestions.ui -import android.content.Context -import android.content.Intent import android.os.Bundle import androidx.core.graphics.Insets import androidx.core.view.updatePadding @@ -40,9 +38,4 @@ class SuggestionsActivity : right = insets.right, ) } - - companion object { - - fun newIntent(context: Context) = Intent(context, SuggestionsActivity::class.java) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt index 92e192e1d..e29e969b8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsFragment.kt @@ -8,11 +8,11 @@ import androidx.core.view.MenuProvider import androidx.fragment.app.viewModels import com.google.android.material.snackbar.Snackbar import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.list.ListSelectionController import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.databinding.FragmentListBinding import org.koitharu.kotatsu.list.ui.MangaListFragment -import org.koitharu.kotatsu.settings.SettingsActivity class SuggestionsFragment : MangaListFragment() { @@ -59,7 +59,7 @@ class SuggestionsFragment : MangaListFragment() { } R.id.action_settings_suggestions -> { - startActivity(SettingsActivity.newSuggestionsSettingsIntent(requireContext())) + router.openSuggestionsSettings() true } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt index e1080f5c1..fe933b5e1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsViewModel.kt @@ -67,7 +67,7 @@ class SuggestionsViewModel @Inject constructor( else -> buildList(list.size + 1) { quickFilter.filterItem(filters)?.let(::add) - mangaListMapper.toListModelList(this, list, mode) + mangaListMapper.toListModelList(this, list, mode, 0) } } }.onStart { diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt index 8f0110f54..7f55dddcd 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/suggestions/ui/SuggestionsWorker.kt @@ -49,6 +49,8 @@ import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.distinctById import org.koitharu.kotatsu.core.model.isNsfw +import org.koitharu.kotatsu.core.nav.AppRouter +import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.parser.MangaRepository import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.asArrayList @@ -62,7 +64,6 @@ import org.koitharu.kotatsu.core.util.ext.sanitize import org.koitharu.kotatsu.core.util.ext.takeMostFrequent import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull import org.koitharu.kotatsu.core.util.ext.trySetForeground -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.explore.data.MangaSourcesRepository import org.koitharu.kotatsu.favourites.domain.FavouritesRepository import org.koitharu.kotatsu.history.data.HistoryRepository @@ -74,8 +75,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder import org.koitharu.kotatsu.parsers.util.almostEquals import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.sizeOrZero -import org.koitharu.kotatsu.reader.ui.ReaderActivity.IntentBuilder -import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler import org.koitharu.kotatsu.suggestions.domain.MangaSuggestion import org.koitharu.kotatsu.suggestions.domain.SuggestionRepository @@ -130,7 +129,7 @@ class SuggestionsWorker @AssistedInject constructor( PendingIntentCompat.getActivity( applicationContext, 0, - SettingsActivity.newSuggestionsSettingsIntent(applicationContext), + AppRouter.suggestionsSettingsIntent(applicationContext), 0, false, ), @@ -326,7 +325,7 @@ class SuggestionsWorker @AssistedInject constructor( style.setBigContentTitle(title) setStyle(style) } - val intent = DetailsActivity.newIntent(applicationContext, manga) + val intent = AppRouter.detailsIntent(applicationContext, manga) setContentIntent( PendingIntentCompat.getActivity( applicationContext, @@ -348,7 +347,7 @@ class SuggestionsWorker @AssistedInject constructor( PendingIntentCompat.getActivity( applicationContext, id + 2, - IntentBuilder(applicationContext).manga(manga).build(), + ReaderIntent.Builder(applicationContext).manga(manga).build().intent, 0, false, ), @@ -360,7 +359,7 @@ class SuggestionsWorker @AssistedInject constructor( PendingIntentCompat.getActivity( applicationContext, 0, - SuggestionsActivity.newIntent(applicationContext), + AppRouter.suggestionsIntent(applicationContext), 0, false, ), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt index c8e71f564..34d342882 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncAuthActivity.kt @@ -20,6 +20,7 @@ import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.ui.BaseActivity +import org.koitharu.kotatsu.core.ui.util.DefaultTextWatcher import org.koitharu.kotatsu.core.util.ext.getDisplayMessage import org.koitharu.kotatsu.core.util.ext.getParcelableExtraCompat import org.koitharu.kotatsu.core.util.ext.observe @@ -187,11 +188,7 @@ class SyncAuthActivity : BaseActivity(), View.OnClickLi private class PasswordTextWatcher( private val button: Button, - ) : TextWatcher { - - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) = Unit - - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) = Unit + ) : DefaultTextWatcher { override fun afterTextChanged(s: Editable?) { val text = s?.toString() diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncSettingsIntent.kt b/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncSettingsIntent.kt deleted file mode 100644 index 2775d8072..000000000 --- a/app/src/main/kotlin/org/koitharu/kotatsu/sync/ui/SyncSettingsIntent.kt +++ /dev/null @@ -1,18 +0,0 @@ -package org.koitharu.kotatsu.sync.ui - -import android.accounts.Account -import android.content.Intent -import android.os.Bundle - -private const val ACCOUNT_KEY = "account" -private const val ACTION_ACCOUNT_SYNC_SETTINGS = "android.settings.ACCOUNT_SYNC_SETTINGS" -private const val EXTRA_SHOW_FRAGMENT_ARGUMENTS = ":settings:show_fragment_args" - -@Suppress("FunctionName") -fun SyncSettingsIntent(account: Account): Intent { - val args = Bundle(1) - args.putParcelable(ACCOUNT_KEY, account) - val intent = Intent(ACTION_ACCOUNT_SYNC_SETTINGS) - intent.putExtra(EXTRA_SHOW_FRAGMENT_ARGUMENTS, args) - return intent -} diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt index 5af404b93..cf9e42e97 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/data/EntityMapping.kt @@ -10,7 +10,7 @@ fun TrackLogWithManga.toTrackingLogItem(): TrackingLogItem { return TrackingLogItem( id = trackLog.id, chapters = chaptersList, - manga = manga.toManga(tags.toMangaTags()), + manga = manga.toManga(tags.toMangaTags(), null), createdAt = Instant.ofEpochMilli(trackLog.createdAt), isNew = trackLog.isUnread, ) diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt index 5aabf2f51..dfe81770f 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/domain/TrackingRepository.kt @@ -60,7 +60,7 @@ class TrackingRepository @Inject constructor( return db.getTracksDao().observeUpdatedManga(limit, filterOptions) .mapItems { MangaTracking( - manga = it.manga.toManga(it.tags.toMangaTags()), + manga = it.manga.toManga(it.tags.toMangaTags(), null), lastChapterId = it.track.lastChapterId, lastCheck = it.track.lastCheckTime.toInstantOrNull(), lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), @@ -73,7 +73,7 @@ class TrackingRepository @Inject constructor( suspend fun getTracks(offset: Int, limit: Int): List { return db.getTracksDao().findAll(offset = offset, limit = limit).map { MangaTracking( - manga = it.manga.toManga(emptySet()), + manga = it.manga.toManga(emptySet(), null), lastChapterId = it.track.lastChapterId, lastCheck = it.track.lastCheckTime.toInstantOrNull(), lastChapterDate = it.track.lastChapterDate.toInstantOrNull(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt index 18d02dd3e..2d1dff299 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugActivity.kt @@ -7,12 +7,12 @@ import androidx.core.graphics.Insets import androidx.core.view.updatePadding import coil3.ImageLoader import dagger.hilt.android.AndroidEntryPoint +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseActivity import org.koitharu.kotatsu.core.ui.BaseListAdapter import org.koitharu.kotatsu.core.ui.list.OnListItemClickListener import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.databinding.ActivityTrackerDebugBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.ui.adapter.ListItemType import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration import javax.inject.Inject @@ -53,6 +53,6 @@ class TrackerDebugActivity : BaseActivity(), OnList } override fun onItemClick(item: TrackDebugItem, view: View) { - startActivity(DetailsActivity.newIntent(this, item.manga)) + router.openDetails(item.manga) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt index b9b8866ec..a995979b0 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/debug/TrackerDebugViewModel.kt @@ -25,7 +25,7 @@ class TrackerDebugViewModel @Inject constructor( private fun List.toUiList(): List = map { TrackDebugItem( - manga = it.manga.toManga(emptySet()), + manga = it.manga.toManga(emptySet(), null), lastChapterId = it.track.lastChapterId, newChapters = it.track.newChapters, lastCheckTime = it.track.lastCheckTime.toInstantOrNull(), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt index 6fb310599..838b3a9c8 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedFragment.kt @@ -16,6 +16,7 @@ import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.flow.drop import org.koitharu.kotatsu.R import org.koitharu.kotatsu.core.exceptions.resolve.SnackbarErrorObserver +import org.koitharu.kotatsu.core.nav.router import org.koitharu.kotatsu.core.ui.BaseFragment import org.koitharu.kotatsu.core.ui.list.PaginationScrollListener import org.koitharu.kotatsu.core.ui.list.RecyclerScrollKeeper @@ -25,7 +26,6 @@ import org.koitharu.kotatsu.core.util.ext.addMenuProvider import org.koitharu.kotatsu.core.util.ext.observe import org.koitharu.kotatsu.core.util.ext.observeEvent import org.koitharu.kotatsu.databinding.FragmentListBinding -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.list.domain.ListFilterOption import org.koitharu.kotatsu.list.ui.adapter.MangaListListener import org.koitharu.kotatsu.list.ui.adapter.TypedListSpacingDecoration @@ -35,7 +35,6 @@ import org.koitharu.kotatsu.main.ui.owners.BottomNavOwner import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaTag import org.koitharu.kotatsu.tracker.ui.feed.adapter.FeedAdapter -import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity import javax.inject.Inject @AndroidEntryPoint @@ -105,8 +104,7 @@ class FeedFragment : override fun onSecondaryButtonClick(tipView: TipView) = Unit override fun onListHeaderClick(item: ListHeader, view: View) { - val context = view.context - context.startActivity(UpdatesActivity.newIntent(context)) + router.openMangaUpdates() } private fun onFeedCleared() { @@ -128,7 +126,7 @@ class FeedFragment : } override fun onItemClick(item: Manga, view: View) { - startActivity(DetailsActivity.newIntent(context ?: return, item)) + router.openDetails(item) } override fun onReadClick(manga: Manga, view: View) = Unit diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt index 598d57455..27869f4ad 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/feed/FeedViewModel.kt @@ -151,7 +151,7 @@ class FeedViewModel @Inject constructor( null } else { UpdatedMangaHeader( - mangaList.map { mangaListMapper.toGridModel(it.manga) }, + mangaList.map { mangaListMapper.toGridModel(it.manga, 0) }, ) } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt index 047b0d7ca..238c6becb 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesActivity.kt @@ -1,7 +1,5 @@ package org.koitharu.kotatsu.tracker.ui.updates -import android.content.Context -import android.content.Intent import android.os.Bundle import androidx.core.graphics.Insets import androidx.core.view.updatePadding @@ -41,9 +39,4 @@ class UpdatesActivity : right = insets.right, ) } - - companion object { - - fun newIntent(context: Context) = Intent(context, UpdatesActivity::class.java) - } } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt index b332edf0a..9cd35d993 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/ui/updates/UpdatesViewModel.kt @@ -107,7 +107,7 @@ class UpdatesViewModel @Inject constructor( prevHeader = header } } - result += mangaListMapper.toListModel(item.manga, mode) + result += mangaListMapper.toListModel(item.manga, mode, 0) } return result } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt index 8f2e55dc8..323bb1f4b 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackWorker.kt @@ -46,6 +46,7 @@ import org.koitharu.kotatsu.browser.cloudflare.CaptchaNotifier import org.koitharu.kotatsu.core.db.MangaDatabase import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException import org.koitharu.kotatsu.core.model.ids +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.TrackerDownloadStrategy import org.koitharu.kotatsu.core.prefs.TriStateOption @@ -59,7 +60,6 @@ import org.koitharu.kotatsu.download.ui.worker.DownloadWorker import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.parsers.util.runCatchingCancellable import org.koitharu.kotatsu.parsers.util.toIntUp -import org.koitharu.kotatsu.settings.SettingsActivity import org.koitharu.kotatsu.settings.work.PeriodicWorkScheduler import org.koitharu.kotatsu.tracker.domain.CheckNewChaptersUseCase import org.koitharu.kotatsu.tracker.domain.GetTracksUseCase @@ -209,7 +209,7 @@ class TrackWorker @AssistedInject constructor( PendingIntentCompat.getActivity( applicationContext, 0, - SettingsActivity.newTrackerSettingsIntent(applicationContext), + AppRouter.trackerSettingsIntent(applicationContext), 0, false, ), diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt index dc18149a5..daab52ec1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/tracker/work/TrackerNotificationHelper.kt @@ -16,14 +16,13 @@ import coil3.ImageLoader import coil3.request.ImageRequest import dagger.hilt.android.qualifiers.ApplicationContext import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.checkNotificationPermission import org.koitharu.kotatsu.core.util.ext.mangaSourceExtra import org.koitharu.kotatsu.core.util.ext.toBitmapOrNull -import org.koitharu.kotatsu.details.ui.DetailsActivity import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.MangaChapter -import org.koitharu.kotatsu.tracker.ui.updates.UpdatesActivity import javax.inject.Inject class TrackerNotificationHelper @Inject constructor( @@ -81,7 +80,7 @@ class TrackerNotificationHelper @Inject constructor( style.setSummaryText(manga.title) style.setBigContentTitle(summary) setStyle(style) - val intent = DetailsActivity.newIntent(applicationContext, manga) + val intent = AppRouter.detailsIntent(applicationContext, manga) setContentIntent( PendingIntentCompat.getActivity( applicationContext, @@ -126,7 +125,7 @@ class TrackerNotificationHelper @Inject constructor( setNumber(newChaptersCount) setGroup(GROUP_NEW_CHAPTERS) setGroupSummary(true) - val intent = UpdatesActivity.newIntent(applicationContext) + val intent = AppRouter.mangaUpdatesIntent(applicationContext) setContentIntent( PendingIntentCompat.getActivity( applicationContext, diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt index a4e047999..4359d1e9d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentListFactory.kt @@ -14,7 +14,7 @@ import coil3.transform.RoundedCornersTransformation import dagger.Lazy import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.util.ext.getDrawableOrThrow import org.koitharu.kotatsu.core.util.ext.mangaExtra @@ -74,7 +74,7 @@ class RecentListFactory( views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder) } val intent = Intent() - intent.putExtra(MangaIntent.KEY_ID, item.id) + intent.putExtra(AppRouter.KEY_ID, item.id) views.setOnClickFillInIntent(R.id.imageView_cover, intent) return views } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt index c94e449b9..380eb1cca 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/recent/RecentWidgetProvider.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.widget.RemoteViews import androidx.core.app.PendingIntentCompat import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseAppWidgetProvider import org.koitharu.kotatsu.reader.ui.ReaderActivity @@ -36,7 +37,7 @@ class RecentWidgetProvider : BaseAppWidgetProvider() { adapter.data = Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)) views.setRemoteAdapter(R.id.stackView, adapter) val intent = Intent(context, ReaderActivity::class.java) - intent.action = ReaderActivity.ACTION_MANGA_READ + intent.action = ReaderIntent.ACTION_MANGA_READ views.setPendingIntentTemplate( R.id.stackView, PendingIntentCompat.getActivity( diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt index 26d4cee95..a6747ffe1 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfListFactory.kt @@ -14,7 +14,7 @@ import coil3.transform.RoundedCornersTransformation import dagger.Lazy import kotlinx.coroutines.runBlocking import org.koitharu.kotatsu.R -import org.koitharu.kotatsu.core.parser.MangaIntent +import org.koitharu.kotatsu.core.nav.AppRouter import org.koitharu.kotatsu.core.prefs.AppSettings import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.image.TrimTransformation @@ -85,7 +85,7 @@ class ShelfListFactory( views.setImageViewResource(R.id.imageView_cover, R.drawable.ic_placeholder) } val intent = Intent() - intent.putExtra(MangaIntent.KEY_ID, item.id) + intent.putExtra(AppRouter.KEY_ID, item.id) views.setOnClickFillInIntent(R.id.rootLayout, intent) return views } diff --git a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt index 5246ecd3f..f56e11e0d 100644 --- a/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt +++ b/app/src/main/kotlin/org/koitharu/kotatsu/widget/shelf/ShelfWidgetProvider.kt @@ -9,6 +9,7 @@ import android.net.Uri import android.widget.RemoteViews import androidx.core.app.PendingIntentCompat import org.koitharu.kotatsu.R +import org.koitharu.kotatsu.core.nav.ReaderIntent import org.koitharu.kotatsu.core.prefs.AppWidgetConfig import org.koitharu.kotatsu.core.ui.BaseAppWidgetProvider import org.koitharu.kotatsu.reader.ui.ReaderActivity @@ -36,7 +37,7 @@ class ShelfWidgetProvider : BaseAppWidgetProvider() { adapter.data = Uri.parse(adapter.toUri(Intent.URI_INTENT_SCHEME)) views.setRemoteAdapter(R.id.gridView, adapter) val intent = Intent(context, ReaderActivity::class.java) - intent.action = ReaderActivity.ACTION_MANGA_READ + intent.action = ReaderIntent.ACTION_MANGA_READ views.setPendingIntentTemplate( R.id.gridView, PendingIntentCompat.getActivity( diff --git a/app/src/main/res/color-v23/bg_floating_button.xml b/app/src/main/res/color-v23/bg_floating_button.xml new file mode 100644 index 000000000..1ebbd47ea --- /dev/null +++ b/app/src/main/res/color-v23/bg_floating_button.xml @@ -0,0 +1,4 @@ + + + + diff --git a/app/src/main/res/color/bg_floating_button.xml b/app/src/main/res/color/bg_floating_button.xml new file mode 100644 index 000000000..b40058de7 --- /dev/null +++ b/app/src/main/res/color/bg_floating_button.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/app/src/main/res/drawable/bg_list_icons.xml b/app/src/main/res/drawable/bg_list_icons.xml new file mode 100644 index 000000000..3ed4e9fe4 --- /dev/null +++ b/app/src/main/res/drawable/bg_list_icons.xml @@ -0,0 +1,10 @@ + + + + + + diff --git a/app/src/main/res/drawable/bg_tab_pill.xml b/app/src/main/res/drawable/bg_tab_pill.xml new file mode 100644 index 000000000..246f0c385 --- /dev/null +++ b/app/src/main/res/drawable/bg_tab_pill.xml @@ -0,0 +1,18 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_disable.xml b/app/src/main/res/drawable/ic_disable.xml new file mode 100644 index 000000000..ed59a0e86 --- /dev/null +++ b/app/src/main/res/drawable/ic_disable.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_move_horizontal.xml b/app/src/main/res/drawable/ic_move_horizontal.xml new file mode 100644 index 000000000..5c67c54df --- /dev/null +++ b/app/src/main/res/drawable/ic_move_horizontal.xml @@ -0,0 +1,11 @@ + + + diff --git a/app/src/main/res/drawable/ic_next.xml b/app/src/main/res/drawable/ic_next.xml new file mode 100644 index 000000000..6c80ccb83 --- /dev/null +++ b/app/src/main/res/drawable/ic_next.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ic_prev.xml b/app/src/main/res/drawable/ic_prev.xml new file mode 100644 index 000000000..8822aa243 --- /dev/null +++ b/app/src/main/res/drawable/ic_prev.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/layout-w600dp-land/activity_details.xml b/app/src/main/res/layout-w600dp-land/activity_details.xml index afeea511a..a1b85dcd5 100644 --- a/app/src/main/res/layout-w600dp-land/activity_details.xml +++ b/app/src/main/res/layout-w600dp-land/activity_details.xml @@ -121,56 +121,17 @@ app:layout_constraintTop_toBottomOf="@id/textView_title" tools:text="@tools:sample/lorem[12]" /> - - - - - + app:layout_constraintTop_toBottomOf="@id/textView_subtitle" + tools:text="@string/add_to_favourites" /> - - + app:constraint_referenced_ids="imageView_cover,chip_favorite" /> - + + app:layout_constraintTop_toBottomOf="@id/textView_progress_label" />